diff --git a/.env.example b/.env.example index 3099694ed8..9e11926efe 100644 --- a/.env.example +++ b/.env.example @@ -244,6 +244,12 @@ ALLOW_API_KEY_REVEAL=false # Default: false (blocked) | Set true to enable local providers. # OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true +# Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN). +# Used by: src/shared/network/outboundUrlGuard.ts — scopes to the provider validation path and +# still blocks cloud-metadata (169.254.169.254, metadata.google.internal). Default: true +# (OmniRoute is local-first). Set false to enforce strict public-only blocking. +# OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS=false + # Legacy alias toggling the SSRF guard. Used by: src/shared/network/outboundUrlGuard.ts # When unset, OmniRoute uses the per-feature defaults. Set to "false"/"0" to disable. # OUTBOUND_SSRF_GUARD_ENABLED=true @@ -432,6 +438,13 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # Set to 1 only for legacy diagnostics. Values above 256 are capped. # OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS=32 +# SOCKS5 handshake (connect) timeout in ms (default 10000, capped at 120000). +# Raise it when a single residential gateway host is hit by high concurrency +# (e.g. 100 simultaneous requests): the real SOCKS5 handshake can exceed 10s +# under a saturated pool even though the proxy is reachable, which otherwise +# surfaces as a false "[Proxy Fast-Fail] Proxy unreachable". +# SOCKS_HANDSHAKE_TIMEOUT_MS=10000 + # Proxy fail-open mode (default: false = fail-closed). # When false, a request whose assigned proxy fails to resolve is REFUSED rather than # falling back to a direct connection — prevents real-IP leaks in egress-controlled diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06dcd7f9e5..d69f0921b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -509,7 +509,7 @@ jobs: - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - name: Cache Next.js build cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 with: path: .build/next/cache key: nextjs-${{ runner.os }}-node-${{ env.CI_NODE_VERSION }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**/*', 'open-sse/**/*', 'db/**/*', 'next.config.mjs', 'tsconfig*.json', 'postcss.config.*', 'tailwind.config.*') }} @@ -624,7 +624,7 @@ jobs: cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts" + - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" test-vitest: name: Vitest (MCP / autoCombo / UI components) @@ -676,7 +676,7 @@ jobs: cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts" + - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" node-26-compat-build: name: Node 26 Compatibility Build @@ -698,7 +698,7 @@ jobs: - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - name: Cache Next.js build cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 with: path: .build/next/cache key: nextjs-${{ runner.os }}-node-${{ env.CI_NODE_26_VERSION }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**/*', 'open-sse/**/*', 'db/**/*', 'next.config.mjs', 'tsconfig*.json', 'postcss.config.*', 'tailwind.config.*') }} @@ -729,7 +729,7 @@ jobs: cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts" + - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" test-coverage-shard: name: Coverage Shard (${{ matrix.shard }}/8) @@ -770,7 +770,7 @@ jobs: --exclude=tests/** \ --exclude=**/*.test.* \ node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \ - --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts" + --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" - name: Upload raw shard coverage if: always() uses: actions/upload-artifact@v7 @@ -1004,7 +1004,7 @@ jobs: - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - name: Cache Playwright browsers - uses: actions/cache@v5.0.5 + uses: actions/cache@v6.0.0 with: path: ~/.cache/ms-playwright key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }} diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index 2b4839153e..73ec01f9df 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -94,7 +94,7 @@ jobs: cache: npm - name: Cache node_modules - uses: actions/cache@v5.0.5 + uses: actions/cache@v6.0.0 with: path: node_modules key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} diff --git a/.github/workflows/nightly-mutation.yml b/.github/workflows/nightly-mutation.yml index 656210666e..2312095012 100644 --- a/.github/workflows/nightly-mutation.yml +++ b/.github/workflows/nightly-mutation.yml @@ -113,7 +113,7 @@ jobs: cache: npm - run: npm ci - name: Restore Stryker incremental cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: reports/mutation/stryker-incremental.json key: stryker-incremental-${{ matrix.batch.name }}-${{ github.run_id }} diff --git a/.github/workflows/nightly-resilience.yml b/.github/workflows/nightly-resilience.yml index ad7a92ee49..e094596a16 100644 --- a/.github/workflows/nightly-resilience.yml +++ b/.github/workflows/nightly-resilience.yml @@ -100,7 +100,7 @@ jobs: cache: npm - run: npm ci - name: Cache Playwright browsers - uses: actions/cache@v5.0.5 + uses: actions/cache@v6.0.0 with: path: ~/.cache/ms-playwright key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc6858653..f310540dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,87 @@ --- +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features @@ -300,6 +381,7 @@ - **fix(security):** do not trust the loopback socket as local-only when the server is behind a reverse proxy, closing a potential auth bypass path. ([#4632](https://github.com/diegosouzapw/OmniRoute/pull/4632)) - **fix(security):** validate the Kiro region parameter to prevent SSRF via crafted region strings (GHSA-6mwv-4mrm-5p3m). ([#4629](https://github.com/diegosouzapw/OmniRoute/pull/4629)) - **fix(copilot):** replace `execSync` shell interpolation with `execFile` in the `runOmniRouteCli` tool to prevent command injection. The user-supplied command is now split into an argv array and passed to `execFile` (no shell), so shell metacharacters are treated as literal text; error output is routed through `sanitizeErrorMessage()`. ([#5024](https://github.com/diegosouzapw/OmniRoute/pull/5024) — thanks @hamsa0x7) +- **fix(db):** replace `Math.random` with `crypto.randomUUID` for database ID generation, removing a non-cryptographic source of collision/predictability in generated identifiers. ([#5026](https://github.com/diegosouzapw/OmniRoute/pull/5026) — thanks @hamsa0x7) --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25ed28a6a4..b91144999d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -167,6 +167,18 @@ npm run coverage:report # Lint + format check npm run lint npm run check + +# Gated real-upstream combo smoke (requires VPS access + real provider credits) +# Hits REAL providers — costs a little. NEVER runs in CI. Skips cleanly without the gate. +# Needs: ssh root@192.168.0.15 access (sources a read-only DB snapshot from the VPS). +RUN_COMBO_LIVE=1 npm run test:combo:live + +# Phase-3 VPS live smoke — plain Node ESM scripts, hit the live .15 server directly. +# Requires: ssh root@192.168.0.15 access (combos created/torn down via SSH sqlite). +# Hits REAL providers (small cost). Creates/deletes only __live_test__* combos. NEVER runs in CI. +# REQUIRE_API_KEY=false on .15 so no API key needed, but honors COMBO_LIVE_BASE_URL / COMBO_LIVE_API_KEY if set. +npm run test:combo:live:vps # 7 HTTP scenarios (priority/round-robin/weighted/cost/fusion/auto + health) +npm run test:combo:live:vps:failover # adds a real cross-provider failover scenario (8 total) ``` Coverage notes: diff --git a/README.md b/README.md index 58458f6e07..87194b1072 100644 --- a/README.md +++ b/README.md @@ -289,18 +289,18 @@ Result: 4 layers of fallback = zero downtime -> Recent highlights from **v3.8.20 → v3.8.37**. Full history in [`CHANGELOG.md`](CHANGELOG.md). +> Recent highlights from **v3.8.20 → v3.8.38**. Full history in [`CHANGELOG.md`](CHANGELOG.md). - **⚖️ Quota-Share routing** — a dedicated combo strategy that spreads load across accounts by _available quota_: Deficit-Round-Robin scheduling, per-connection `max_concurrent` with cooldown-wait queueing, multi-window usage buckets (5h / 7d / per-model), per-(key,model) caps, session stickiness for prompt-cache integrity, and proactive saturation from upstream token-usage headers. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) - **🤖 One-command CLI/agent setup** — a dedicated `setup-*` command configures each coding tool to route through OmniRoute (Claude Code, Codex, Cline, Continue, Cursor, Roo Code, Kilo Code, Crush, Goose, Qwen Code, Aider, OpenCode, Gemini CLI); `omniroute launch` / `omniroute launch-codex` are zero-config launchers. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md) - **🛰️ Remote mode** — drive a remote OmniRoute from any machine with scoped access tokens (`omniroute connect` / `omniroute contexts` / `omniroute tokens`). → [Remote Mode](docs/guides/REMOTE-MODE.md) - **🧭 Smarter auto-routing** — OpenRouter-style `auto/:` combos (e.g. `auto/coding:fast`, `auto/reasoning:pro`), a **Fusion** strategy (16th — fan out to a panel of models in parallel, then synthesize via a judge), **task-aware routing** (best-fit connection per task type), per-request `X-Route-Model` override, live Arena-ELO + models.dev model intelligence, per-step account allowlists, provider-wildcard combo steps, nested combo-ref execution, sticky weighted selection, and `web_search`-aware routing. → [Auto-Combo](docs/routing/AUTO-COMBO.md) -- **🗜️ Pluggable compression** — an async pipeline of **9 composable engines** with Compression Studios, an LLMLingua-2 ONNX engine and a heuristic/SLM two-tier **Ultra**, RTK, delegated Anthropic Context Editing, **Output Styles** (output-axis steering: terse-prose / less-code / terse-CJK), an **adaptive context-budget dial** (escalate only as far as needed to fit the context window), per-request `x-omniroute-compression` control, an opt-in offline eval harness, one-click **Headroom** proxy lifecycle management from the dashboard (Docker sidecar supported), and a unified panel with named profiles + an active-profile selector. → [Compression](docs/compression/COMPRESSION_ENGINES.md) +- **🗜️ Pluggable compression** — an async pipeline of **9 composable engines** with Compression Studios, an LLMLingua-2 ONNX engine and a heuristic/SLM two-tier **Ultra**, RTK, delegated Anthropic Context Editing, **Output Styles** (output-axis steering: terse-prose / less-code / terse-CJK), an **adaptive context-budget dial** (escalate only as far as needed to fit the context window), per-request `x-omniroute-compression` control, an opt-in offline eval harness, one-click **Headroom** proxy lifecycle management from the dashboard (Docker sidecar supported), a synthetic **compression playground** (Play lanes + A/B Compare with USD-capped fidelity verdicts), an opt-in **per-step fidelity gate** that rejects a lossy engine before it degrades the prompt, and a unified panel with named profiles + an active-profile selector. → [Compression](docs/compression/COMPRESSION_ENGINES.md) - **🕵️ Transparent MITM decrypt (TPROXY)** — capture & translate traffic from CLIs that ignore proxy env vars, with a per-SNI certificate authority and a trust-store installer. → [MITM/TPROXY](docs/security/MITM-TPROXY-DECRYPT.md) - **💸 Cost telemetry everywhere** — `X-OmniRoute-*` cost/usage headers on every endpoint (including media), a non-token cost engine, a cache-HIT `X-OmniRoute-Cost-Saved` header, and per-key USD spend quotas. → [API Reference](docs/reference/API_REFERENCE.md) - **🧠 Memory you control** — opt-in int8 vector quantization (Qdrant + sqlite-vec), memory off by default, and a per-request `x-omniroute-no-memory` header. → [Memory](docs/frameworks/MEMORY.md) - **🛡️ Security** — a prompt-injection guard across every LLM route (backed by a red-team suite), plus a free DuckDuckGo last-resort web search. → [Guardrails](docs/security/GUARDRAILS.md) -- **🤝 More providers & agents** — Cursor Cloud Agent (a 4th cloud agent), CodeBuddy CN (`copilot.tencent.com`), a Google Flow video-generation provider, new gateways **DGrid** and **Pioneer AI** (Fastino Labs), inbound **xAI Grok** translators, GPT-4 / GPT-4o-mini on the GitHub Copilot provider, multi-model **Factory Droid**, a refreshed 231-provider catalog (OrcaRouter, Wafer AI, OpenAdapter, dit.ai, TokenRouter, …), Vertex AI media generation (speech / transcription / music / video), and one-click account import from CLIProxyAPI (`~/.cli-proxy-api/`). → [Providers](docs/reference/PROVIDER_REFERENCE.md) +- **🤝 More providers & agents** — Cursor Cloud Agent (a 4th cloud agent), CodeBuddy CN (`copilot.tencent.com`), a Google Flow video-generation provider, new gateways **DGrid** and **Pioneer AI** (Fastino Labs), inbound **xAI Grok** translators plus **Grok Build (xAI)** with an OAuth import-token flow, GPT-4 / GPT-4o-mini on the GitHub Copilot provider, multi-model **Factory Droid**, **ZenMux Free** (session-cookie free tier), **Alibaba DashScope** text-to-video (`wan2.7-t2v`), a refreshed 231-provider catalog (OrcaRouter, Wafer AI, OpenAdapter, dit.ai, TokenRouter, …), Vertex AI media generation (speech / transcription / music / video), and one-click account import from CLIProxyAPI (`~/.cli-proxy-api/`). → [Providers](docs/reference/PROVIDER_REFERENCE.md) - **⚡ Local performance & infra** — a one-click local Redis launcher (`omniroute redis up`, plus a dashboard Redis panel), one-click **Cloudflare Workers** and **Deno Deploy** relay deployers wired into the proxy pool, and an optional Bifrost Go sidecar that offloads the hottest relay path (`BIFROST_BASE_URL`, with automatic fallback to the TypeScript path on timeout). → [Environment](docs/reference/ENVIRONMENT.md)
diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index cdff5b6401..f62a65767f 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,6 +1,9 @@ { "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", - "count": 1963, + "count": 1980, + "_rebaseline_2026_06_27_v3838_release": "1978->1980 (+2). v3.8.38 cycle-close drift surfaced by the release-green pre-flight (check:complexity does NOT run on PR->release fast-gates). +2 from late-cycle feature/fix merges (compression fidelity-gate steps #5143, SSE hardening). Release-finalize working tree touches ONLY CHANGELOG.md + i18n mirrors + the 2 baseline JSONs — 0 production-code change. Structural reduction tracked in #3501.", + "_rebaseline_2026_06_26_v3838_ownerprs_batch": "1972->1978 (+6). Drift do lote de merges de PRs do dono + contribuidores em release/v3.8.38 (sessao /review-prs): #4845 (antigravity convertGeminiToOpenAI), #5105 (executor zenmux-free), #5020 (executor grok-cli), #4940 (usage dedupe guard), #5093 (resilience: quota cutoff/gemini mime/model-lockout cooldown), #5015 (quota hydration + auto-combo scoping). Cada um e crescimento de feature/fix legitimo recem-TDD'd, nao regressao; o gate check:complexity NAO roda no fast-path PR->release, entao o ramo acumula sem rebaselinar (mesma familia dos rebaselines anteriores). #5121 cookie-dedup foi mantido complexity-NEUTRO via extracao do helper findExistingCookieConnection. Reducao estrutural fica como debt (#3501).", + "_rebaseline_2026_06_26_v3838_release_fast_gate": "1963->1972 (+9). Reconciles inherited release/v3.8.38 drift surfaced by PR #5124. Local origin/release/v3.8.38 measured 1971 and this PR also measured 1971 after refactoring the new JSON-body SSE sniffing path, while the GitHub Ubuntu fast gate measured 1972; use the CI-observed value so the release branch gate is deterministic. The streaming fix is complexity-net-zero relative to the local release base. The release fast-path does not consistently ratchet complexity between release-cycle merges; keep structural reductions as separate debt.", "_rebaseline_2026_06_25_v3836_release": "Reconciliacao release-volatil 1920->1950 (+30) no fechamento do ciclo v3.8.36, surfada pelo CI da fix-PR #5029 (a catraca de complexidade NAO roda no fast-path PR->release nem foi medida no release PR #4854 — Quality Ratchet foi SKIPPED la — so PR->main, entao o ramo acumulou os 137 commits sem rebaselinar). O +30 e drift de condicionais NOVOS das features legitimas do ciclo: Quota-Share Fase 2/3 (estrategia dedicada DRR+P2C, multi-window buckets, concurrency control, headroom, saturacao proativa — #4885/#4907/#4908/#4927/#4928/#4929/#4939/#4965/#4967/#4970), task-aware + Fusion combo (#4945/#4652), e ramos de provider/translator de contribuidores. A god-file decomposition #3501 e PURA (move codigo p/ leaves, complexity-neutra). Verificado que esta fix-PR (#5029) toca SO scripts/build/pack-artifact-policy.ts (array de strings), tests/integration/resilience-http-e2e.test.ts (2 keys) e os 2 baselines json — contribui 0 ao gate que varre src+open-sse+electron+bin. Mesma familia dos rebaselines anteriores — crescimento de feature legitimo recem-TDD'd, nao regressao; reducao estrutural fica como debt (#3501).", "_rebaseline_2026_06_23_v3835_release": "Reconciliacao release-volatil 1916->1920 (+4) no fechamento do ciclo v3.8.35, surfada pelo pre-flight check:release-green (a catraca de complexidade NAO roda no fast-path PR->release, so release->main, entao o ramo acumula sem rebaselinar). O +4 e drift de condicionais NOVOS dos merges de contribuidor/feature deste ciclo (Compression Phase 4 #4694/#4707/#4716/#4720, combos auto-promote #4774, tier no-auth #4753, deepseek-web tool-fold #4756, dedupe provider nodes #4768). Verificado que o trabalho de release-finalize desta sessao toca SO docs/*.md (THREAT_MODEL), CHANGELOG.md, baselines e 1 linha de string em scripts/check/check-fabricated-docs.mjs (fora do escopo src+open-sse+electron+bin que o gate varre) — contribui 0. Mesma familia dos rebaselines anteriores — crescimento de feature legitimo recem-TDD'd, nao regressao; reducao estrutural fica como debt (#3501).", "_rebaseline_2026_06_23_v3834_release": "Reconciliacao release-volatil 1915->1916 (+1) no fechamento do ciclo v3.8.34. check:complexity NAO roda no fast-path PR->release (so release->main), entao o ramo acumula sem rebaselinar; surfou no full CI do release PR (run em c98e7ff6d). O +1 e drift de condicional NOVO de merge de contribuidor do ciclo (features quota/usage/opencode-go/M365). Verificado que o commit de release-finalize NAO adiciona complexity: toca CHANGELOG/baseline/mirrors/3 testes + 1 linha de regex em opencodeOllamaUsage.ts (sem novo ramo) + reorder de dados no reka registry — local mede 1916 com ou sem essa mudanca. Mesma familia dos rebaselines anteriores — crescimento de feature legitimo, nao regressao; reducao estrutural fica como debt (#3501).", diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 1c59b8f75e..fd6df33e3c 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,11 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_06_26_v3838_ownerprs_batch": "Lote /review-prs v3.8.38 (PRs do dono + contribuidores): src/lib/db/providers.ts 1093->1107 (+14 = #5121 cookie-dedup branch extraido para o helper findExistingCookieConnection — a extracao reduz a complexidade ciclomatica de createProviderConnection mas adiciona ~14 linhas ao arquivo; trade-off consciente complexity-vs-file-size) e src/lib/usage/usageHistory.ts 934->983 (+49 = #4940 dedup guard SELECT-before-INSERT + endpoint backfill + scheduleStatsEvent debounce). Crescimento de feature/fix legitimo recem-TDD'd; o fast-path PR->release nao roda check:file-size, entao o ramo acumula sem rebaselinar. Reducao estrutural fica como debt (#3501).", + "_rebaseline_2026_06_26_relgreen_db_test": "Release-green follow-up: tests/unit/db-core-init.test.ts 867->877 (+10 = the invalid-DATA_DIR test now captures the rejection and asserts both Error type AND message — restores net-neutral assert count after #5117's consolidation, satisfying check:test-masking — instead of a single assert.rejects).", + "_rebaseline_2026_06_26_5074_fusion_editor": "PR #5074 own growth: src/app/(dashboard)/dashboard/combos/page.tsx 4485->4594 (+109 = the Fusion judgeModel + fusionTuning editor block — text/number inputs wired through updateFusionTuning, schema-validated) and tests/unit/combo-config.test.ts 800->881 (testFrozen add: 5 new Fusion-config schema tests). Cohesive UI block at the strategy-conditional editor; combos/page.tsx structural shrink tracked in #3501. Covered by combo-config.test.ts.", + "_rebaseline_2026_06_26_5101_responses_textformat": "PR #5101 own growth: open-sse/executors/default.ts 859->876 (+17 = defaultResponsesTextFormat — fills the Responses-API default text.format for openai-compatible-*responses* providers so LM-Studio-style upstreams stop 400ing; guarded, never overwrites an existing format). Irreducible executor chokepoint next to applyJsonSchemaFallback; covered by tests/unit/responses-default-text-format.test.ts.", + "_rebaseline_2026_06_26_5117_basereds": "Base-red repair #5117 own growth + sibling drift: open-sse/handlers/responseSanitizer.ts 1122->1139 (+17 = synthesize an output[] message item from an output_text-only Responses body so the answer is not dropped/false-502'd; #4942 regression, covered by tests/unit/response-sanitizer.test.ts). src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx 866->868 (+2 sibling drift from a separately-merged PR, already on the release tip; rebaselined here to green the file-size gate).", + "_rebaseline_2026_06_26_leva5": "Leva 5 (26 PRs, release/v3.8.38, tip 7b6a9ed33): own growth from merged PRs — cursor.ts 1474->1577 (#4912 composer inline tool-calls wiring), kiro.ts 814->944 (#4911 inline-thinking splitter wiring), videoGeneration.ts 1083->1265 (#5051 Alibaba DashScope handler), default.ts 835->859 (#4727 client_metadata strip), base.ts 1470->1475 (#4846 anthropic header normalize call), chat.ts 1552->1560 (#5064 self-inflicted-timeout cooldown skip). All covered by per-PR tests; growth is localized feature/fix code next to existing branches.", "_rebaseline_2026_06_26_rc17b_leva4": "Leva 4 (22 PRs): drift dos commits definidores. route.ts (#4931 DGrid), settings.ts (#4852/#4856), chat.ts (#4852/#4858/#4870), antigravity.ts (#4941 retry), codex.ts (#4849), request/openai-responses.ts (#4859/#4862), response/openai-responses.ts (#4862/#4906 dense+custom-tool), kiro.ts NOVO>800 (#4855), openai-to-claude.ts NOVO>800 (#4853); testes: executor-codex (#4849), translator-openai-responses-req (#4859).", "_rebaseline_2026_06_25_rc17b_leva3": "Leva 3 (24 PRs port do dono): drift de tamanho dos commits definidores mergeados. chat.ts/auth.ts (#4825 bare-model resolve), base.ts (#4820 strip X-Stainless), cursor.ts (#4842 Composer sentinel), default.ts (#4823 anthropic-version case-insensitive), tokenRefresh.ts (#4818 refreshLeadMs), openai-to-gemini.ts (#4821 strip builtin tools), openai-to-kiro.ts (#4816 reject [1m]), response/openai-responses.ts (#4848 close reasoning), request/openai-responses.ts NOVO @808 (#4789 filter nameless tools); testes: executor-default-base (#4823), provider-validation-specialty (#4819 HF whoami), translator-openai-to-kiro (#4816).", "_rebaseline_2026_06_25_4976_ratelimit_400_failover": "Bug fix #4976: a 400 carrying rate-limit text (e.g. MiMoCode 'Detected high-frequency non-compliant requests') was classified as a non-fallbackable generic 400, so MiMo-auto never failed over -> 502 in Cline. open-sse/services/accountFallback.ts 1762->1773 (+11 = a bounded ReDoS-safe RATE_LIMIT_TEXT_PATTERNS array + a single check in the 400 branch returning buildRetryableFallback(RATE_LIMIT_EXCEEDED) at connection-cooldown scope, placed AFTER malformed/overflow detection so a malformed 400 keeps its #2101 MODEL_CAPACITY guard). Irreducible classification wiring at the existing 400 branch; the pattern array is the minimal addition. Covered by tests/unit/accountfallback-ratelimit-400-4976.test.ts (EN+zh rate-limit text -> fallback; generic 400 stays non-fallback; malformed 400 stays MODEL_CAPACITY).", @@ -8,6 +14,7 @@ "_rebaseline_2026_06_24_combo_cooldown_wait_quota_share": "Feature quota-share combo cooldown-aware retry (Variante A) own growth: open-sse/services/combo.ts 3225->3293 (+68 = the cooldown-wait wrap inside handleComboChat's quota-share path. The existing setTry loop body is LEFT at its original indentation: instead of an outer `while (true)` (which would re-indent ~1600 lines and bloat the review), the setTry loop is hoisted into a small recursive closure `dispatchWithCooldownRetry`, and a wait+redispatch is a tail `return dispatchWithCooldownRetry()` — re-running ONLY the set loop (exactly the prior continue-to-top-of-set-loop semantics) while selection/shadow-routing/setup above stay untouched. At the 429 crystallization point the lock reason is resolved via getModelLockoutInfo, the decision via the new pure resolveComboCooldownWaitDecision, then await waitForCooldownAwareRetry (499 on abort), decrement the budget, recurse. Gated to strategy==='quota-share' && comboCooldownWait.enabled. `git diff` == `git diff -w` for combo.ts (zero re-indentation noise). The gating policy + reason resolution are extracted to the new pure leaf open-sse/services/combo/comboCooldownRetry.ts (1098 (+115 = the ComboCooldownWaitCard exposing enabled/maxWaitMs/maxAttempts/budgetMs in Settings > Resilience, mirroring WaitForCooldownCard; wired through GET/PATCH in src/app/api/resilience/route.ts + comboCooldownWaitSettingsSchema in src/shared/validation/schemas/settings.ts; new UI labels use the t(key)||English-fallback pattern, en-only). Structural shrink of combo.ts + ResilienceTab tracked in #3501.", "_rebaseline_2026_06_24_quota_share_concurrency_limit": "Feature FASE 2.1 (per-connection concurrency limit for quota-share combos) own growth. The quota-share gating in selectQuotaShareTarget is FAIL-OPEN (an at-cap connection is only deprioritized, never hard-blocked), so with a single-connection subscription-account pool concurrent requests still flood the account — empirically proven on the .15 deploy: 3 concurrent share-key calls to one minimax connection with max_concurrent=1 were all dispatched within 94ms. This adds a per-CONNECTION semaphore around the quota-share dispatch so excess concurrent requests WAIT in the queue instead of flooding (key = qsconn:, cap = the connection's max_concurrent; fail-open on a saturated queue/timeout to never worsen availability). open-sse/services/combo.ts 3306->3340 (+34 = the irreducible chokepoint wiring: the quotaShareConcurrencyEnabled gate, the acquire (lookupPositiveCap + acquireQuotaShareConcurrencySlot) around dispatchWithCooldownRetry, the release in the outer finally, and the updated cooldown-wait comment). ALL extractable logic lives in the new pure leaf open-sse/services/combo/quotaShareConcurrency.ts (841 (+41 = QuotaShareConcurrencyLimitSettings interface + default {enabled:true} + normalizeQuotaShareConcurrencyLimitSettings + resolve/merge/legacy-fallback wiring; crossed the 800 new-file cap, mirrors the existing comboCooldownWait block). src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx 1098->1183 (+85 = QuotaShareConcurrencyLimitCard, a kill-switch toggle mirroring ComboCooldownWaitCard; wired through GET/PATCH in src/app/api/resilience/route.ts + quotaShareConcurrencyLimitSettingsSchema in src/shared/validation/schemas/settings.ts; t(key)||English-fallback, en-only). Covered by tests/unit/combo/quota-share-concurrency.test.ts + tests/unit/resilience-settings-quota-share-concurrency.test.ts. Structural shrink of combo.ts + ResilienceTab tracked in #3501.", "_rebaseline_2026_06_23_4774_combo_legacy_strip": "PR #4774 (KooshaPari, #4382 round-trip) own growth: src/app/(dashboard)/dashboard/combos/page.tsx 4434->4456 (+22 = the client-side LEGACY_COMBO_RESILIENCE_KEYS Set gains queueTimeoutMs + the 12 v3.8.31-era removed keys (queueDepth/fallbackDelayMs/handoffProviders/maxComboDepth/manifestRouting/complexityAwareRouting/pipeline_enabled/pipelineConcurrency/shadowRouting/evalRouting/resetAwareEnabled/resetAwareWindow) with explanatory comments, mirroring the server-side strip list in src/app/api/combos/[id]/route.ts so the modal never re-introduces removed keys on Save). Functional strip list, not a movable block; combos/page.tsx structural shrink tracked in #3501. Covered by tests/unit/combo-config.test.ts (auto-promote + passthrough + legacy-key round-trip).", + "_rebaseline_2026_06_26_3368_cookie_dedup": "Issue #3368 PR6 own growth: src/lib/db/providers.ts 1063->1093 (+30 = the cookie-auth dedup branch in createProviderConnection — name-based upsert + credential-value match, mirroring the existing oauth/apikey dedup so bulk web-session import stops creating duplicate connections on re-import). The pure credential-key + JSON-parse helpers (webSessionCredentialKey, parseProviderSpecificData) were extracted to the new src/lib/db/webSessionDedup.ts (1063 (+13 = resolveProviderNodeForConnection at the existing provider-node lookup — resolves a connection node by exact id OR the bare derived type when unambiguous, + import). Pure selection logic in the new src/lib/db/providerNodeSelect.ts (2289 (+10): #4530 só fiou maxCooldownMs nos 3 sites de combo.ts; os 4 sites de markAccountUnavailable (per-model quota, grok-web 403, per-model 403, local 404) nunca passavam o cap → resolvo mlSettings uma vez e passo maxCooldownMs em todos. tests/unit/db-core-init.test.ts 864->867 (+3): comentário explicando o cap intencional busy_timeout 5s->2s do v3.8.32. Wiring necessário ao chokepoint de lockout; não extraível. Coberto por model-lockout-max-cooldown.test.ts + db-core-init.test.ts. (Demais base-reds — áudio mp3 #912/#913 dedup de handler em geminiHelper.ts, e closure #3578 src/models/ no package.json files — não cresceram arquivo congelado.)", "_rebaseline_2026_06_21_v3833_cycle_open_latent_filesize": "Abertura do ciclo v3.8.33: 4 arquivos cresceram no ciclo v3.8.32 sem bump de baseline e o drift escapou do fast-path do release (check:file-size não roda nas fast-gates p/ release/*, só no PR→main full CI, e o crescimento veio de commits entre fca66c644 e o head do merge 912239f46 — ex. #4475 targetFormat). Medido em origin/main (idêntico, cherry-picks deste ciclo NÃO tocam estes 4): open-sse/services/usage.ts 3408->3414, src/lib/db/core.ts 1820->1825, src/lib/usage/providerLimits.ts 949->950, src/shared/constants/providers.ts 3242->3243. Reconcílio ao valor real de main p/ abrir o .33 verde; shrink estrutural rastreado em #3501.", @@ -120,11 +127,11 @@ "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", "open-sse/config/providerRegistry.ts": 4731, "open-sse/executors/antigravity.ts": 1806, - "open-sse/executors/base.ts": 1470, + "open-sse/executors/base.ts": 1475, "open-sse/executors/chatgpt-web.ts": 2870, "open-sse/executors/claude-web.ts": 1057, "open-sse/executors/codex.ts": 1541, - "open-sse/executors/cursor.ts": 1474, + "open-sse/executors/cursor.ts": 1577, "open-sse/executors/deepseek-web.ts": 1148, "_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.", "_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.", @@ -135,10 +142,10 @@ "open-sse/handlers/audioSpeech.ts": 1061, "open-sse/handlers/chatCore.ts": 5125, "open-sse/handlers/imageGeneration.ts": 3777, - "open-sse/handlers/responseSanitizer.ts": 1122, + "open-sse/handlers/responseSanitizer.ts": 1139, "open-sse/handlers/search.ts": 1546, "open-sse/handlers/sseParser.ts": 830, - "open-sse/handlers/videoGeneration.ts": 1083, + "open-sse/handlers/videoGeneration.ts": 1265, "open-sse/mcp-server/schemas/tools.ts": 1497, "open-sse/mcp-server/server.ts": 1555, "open-sse/mcp-server/tools/advancedTools.ts": 1118, @@ -151,7 +158,8 @@ "_rebaseline_2026_06_24_quota_share_strategy": "Dedicated quota-share strategy (Phase 3 #9): combo.ts 3180->3190 (+10 = one new `else if (strategy === \"quota-share\")` dispatch branch in handleComboChat that delegates 100% to selectQuotaShareTarget + its log line, plus the import). All the new logic lives OUT of the god-file in two new leaves under open-sse/services/combo/: quotaShareInflight.ts (in-flight counter with TTL/lease, ~150 LOC 3225 (+35) = one new `else if (strategy === \"task-aware\")` dispatch branch delegating 100% to selectTaskAwareTarget + its imports/log lines. All scoring/classification logic lives OUT of the god-file in the new leaf open-sse/services/taskAwareRouting.ts (553 LOC 854, -35), but the StackOptions.fidelityGate field, the `const fidelityGate` reads at the two stacked-loop dispatch chokepoints, and the import of FidelityGateConfig are irreducible wiring that cannot leave strategySelector without an architectural refactor of the pre-existing stacked pipeline. Net: 889->854 (+6 vs the pre-Milestone-B frozen 848). Covered by tests/unit/compression/*.test.ts (940 pass).", + "open-sse/services/compression/strategySelector.ts": 854, "open-sse/services/rateLimitManager.ts": 1035, "open-sse/services/tokenRefresh.ts": 2103, "open-sse/services/usage.ts": 3454, @@ -167,7 +175,7 @@ "src/app/(dashboard)/dashboard/cache/page.tsx": 845, "src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 900, "src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 922, - "src/app/(dashboard)/dashboard/combos/page.tsx": 4485, + "src/app/(dashboard)/dashboard/combos/page.tsx": 4594, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1495, "src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1007, "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2612, @@ -175,7 +183,7 @@ "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": 942, - "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 866, + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 868, "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, @@ -206,7 +214,7 @@ "src/lib/db/core.ts": 1825, "src/lib/db/migrationRunner.ts": 1125, "src/lib/db/models.ts": 1259, - "src/lib/db/providers.ts": 1063, + "src/lib/db/providers.ts": 1107, "src/lib/db/proxies.ts": 1060, "src/lib/db/settings.ts": 1155, "src/lib/db/usageAnalytics.ts": 925, @@ -218,21 +226,21 @@ "src/lib/tailscaleTunnel.ts": 1202, "src/lib/usage/callLogs.ts": 975, "src/lib/usage/providerLimits.ts": 955, - "src/lib/usage/usageHistory.ts": 934, + "src/lib/usage/usageHistory.ts": 983, "src/shared/components/OAuthModal.tsx": 960, "src/shared/components/RequestLoggerV2.tsx": 1316, "src/shared/components/analytics/charts.tsx": 1558, "src/shared/constants/cliTools.ts": 875, "src/shared/constants/pricing.ts": 1662, "src/shared/constants/providers.ts": 3276, - "src/shared/constants/sidebarVisibility.ts": 1100, + "src/shared/constants/sidebarVisibility.ts": 1198, "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2523, - "src/sse/handlers/chat.ts": 1552, + "src/sse/handlers/chat.ts": 1575, "src/sse/services/auth.ts": 2336, - "open-sse/executors/default.ts": 835, + "open-sse/executors/default.ts": 876, "open-sse/translator/request/openai-responses.ts": 902, - "open-sse/executors/kiro.ts": 814, + "open-sse/executors/kiro.ts": 944, "open-sse/translator/request/openai-to-claude.ts": 805 }, "testCap": 800, @@ -249,7 +257,7 @@ "tests/unit/chatgpt-web.test.ts": 2855, "tests/unit/combo-routing-engine.test.ts": 3213, "tests/unit/combo-strategy-fallbacks.test.ts": 880, - "tests/unit/db-core-init.test.ts": 867, + "tests/unit/db-core-init.test.ts": 877, "tests/unit/db-migration-runner.test.ts": 1491, "tests/unit/db-settings-crud.test.ts": 941, "tests/unit/deepseek-web.test.ts": 1081, @@ -281,7 +289,8 @@ "tests/unit/translator-openai-to-kiro.test.ts": 999, "tests/unit/translator-resp-gemini-to-openai.test.ts": 1234, "tests/unit/usage-service-hardening.test.ts": 1633, - "tests/unit/vscode-token-routes.test.ts": 1212 + "tests/unit/vscode-token-routes.test.ts": 1212, + "tests/unit/combo-config.test.ts": 881 }, "_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.", "_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap.", @@ -328,5 +337,6 @@ "_rebaseline_2026_06_22_phase4b_slm_tier_ultra": "Compression Phase 4 (B) SLM tier own growth: open-sse/services/compression/strategySelector.ts 783->818 (+35 at the existing applyUltraAsync chokepoint). The no-modelPath ultra branch (previously a one-line passthrough to the sync applyCompression) now runs the two-tier resolver: it adapts the body, builds the ultraConfig (threading config.ultraEngine + preserveSystemPrompt), awaits the now-async ultraCompress (SLM Tier-B when ultraEngine===slm and the worker backend is available, else fail-open to the Tier-A heuristic), and threads result.stats.ultraTier into the returned CompressionStats so the resolved tier reaches the D0 telemetry persister. The sync applyCompression ultra branch is also re-pointed to the new pure ultraCompressHeuristic. The two-tier resolver + the pure heuristic live in open-sse/services/compression/ultra.ts and the thin SLM entry in engines/llmlingua/ultraEntry.ts (both 848 (+30 at the existing selectCompressionPlan dispatch chokepoint). selectCompressionPlan gains an 8th optional `adaptiveOptions` param (modelContextLimit/requestMaxTokens/onAdaptive sink) and, after resolveBasePlan and before the caching-aware pass, runs the PURE resolveAdaptivePlan when config.contextBudget.mode is floor|replace-autotrigger; the new adaptiveEnabled(config) helper also gates the legacy shouldAutoTrigger branch inside resolveBasePlan off when adaptive owns automatic-by-size escalation (D-C4). The escalation ladder, target computation, and the resolver itself live in open-sse/services/compression/adaptiveCompression/{computeTarget,ladder,resolveAdaptivePlan,types}.ts (all 1122 (+19 = SanitizeOpenAIResponseOptions interface + stripReasoning option, #4678); tokenRefresh.ts 2070->2090 (+20 = codex 401 defense-in-depth unrecoverable-refresh guard, #4686); token-refresh-service.test.ts 1322->1353 (+31 = 401-unfamiliar-payload regression case, #4686); translator-openai-responses-req.test.ts 1047->1050 (+3 = reasoning_effort non-Copilot assertion update, #4688). All are the merged PRs own surgical additions at existing chokepoints.", - "_rebaseline_2026_06_25_rc17b_leva2": "rc17 leva2 PR batch own growth (cohesive, not extractable): providerLimits.ts 950->955 (#4786 generalized accesstoken fallback); default.ts NEW frozen entry at 828 (#4729 anthropic-compatible Bearer + #4766 json_schema fallback + #4787 cline workos headers — three provider-specific header branches); openai-to-kiro.ts 807->814 (#4763 Claude-capability image gate); openai-responses.ts 923->937 (#4764 computeFinishReason guard); executor-default-base.test.ts 1339->1440 (#4766 json_schema fallback tests); translator-openai-to-kiro.test.ts 918->980 (#4763 non-Claude image gate tests)." + "_rebaseline_2026_06_25_rc17b_leva2": "rc17 leva2 PR batch own growth (cohesive, not extractable): providerLimits.ts 950->955 (#4786 generalized accesstoken fallback); default.ts NEW frozen entry at 828 (#4729 anthropic-compatible Bearer + #4766 json_schema fallback + #4787 cline workos headers — three provider-specific header branches); openai-to-kiro.ts 807->814 (#4763 Claude-capability image gate); openai-responses.ts 923->937 (#4764 computeFinishReason guard); executor-default-base.test.ts 1339->1440 (#4766 json_schema fallback tests); translator-openai-to-kiro.test.ts 918->980 (#4763 non-Claude image gate tests).", + "_rebaseline_2026_06_27_v3838_filesize_drift": "Mid-cycle drift on release/v3.8.38 — feature/fix growth from already-merged PRs that the fast-path (PR->release skips check:file-size) let accumulate without a bump. src/shared/constants/sidebarVisibility.ts 1100->1198 (+98 = #3812 colored menu-icon support, per-item accent map across the sidebar entries; #5142 then dropped one orphan settings entry, net still above the frozen). src/sse/handlers/chat.ts 1560->1575 (+15 = #5064 self-inflicted-timeout cooldown skip + #5124 long OpenAI-compatible SSE hardening + #5110 embed-WS LIVE_WS_HOST honour / early empty-message reject). Localized feature/fix code next to existing branches, each covered by its own PR tests; not extractable without hiding the chokepoint. Structural shrink of chat.ts tracked in #3501." } diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 40ee0e1194..371c14bf62 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -2,8 +2,9 @@ "_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": 3987, + "value": 4002, "direction": "down", + "_rebaseline_2026_06_27_v3838_release": "3987->4002 (+15). v3.8.38 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle's ~78 commits — provider adds Factory/Grok-Build/ZenMux-Free/Alibaba-video, ~30 SSE/translator/diagnostics fixes, compression fidelity-gate + playground #5080/#5143, Fusion editor #5074, salvage batches #5138/#5141). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, README.md and these baselines — 0 production-code change, so all +15 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.", "_rebaseline_2026_06_25_v3836_release": "v3.8.36 cycle drift surfaced by the post-merge fix PR #5029 (the Quality Ratchet was SKIPPED on the release PR #4854 itself, and does NOT run on the PR→release fast-gates, so warnings accrued unmeasured across this cycle's 137 commits — Quota-Share Fase 2/3 features, god-file decomposition #3501/#4811-#4956, 14 external contributor PRs). 3912→3970 (+58), the exact value measured by the CI Quality Ratchet on #5029. Trust-but-verify: this fix PR touches ONLY scripts/build/pack-artifact-policy.ts (a string-literal allowlist array, scripts/ is eslint-light) and tests/integration/resilience-http-e2e.test.ts (2 string keys, no `any`) — 0 new warnings, so all +58 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Same precedent as _rebaseline_2026_06_23_v3835_release. Tighten via --require-tighten next cycle.", "_rebaseline_2026_06_23_v3835_release": "v3.8.35 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR→release fast-gates, so warnings accrued across this cycle's parallel-session merges — Compression Phase 4 #4694/#4707/#4716/#4720, chatCore #3501 leaf extractions, contributor PRs #4726/#4753/#4774/#4781/#4783/#4793, etc.). 3907→3912 (+5). Verified my release-finalize working tree touches ONLY docs/*.md (THREAT_MODEL), CHANGELOG.md, baselines, and 1 string line in scripts/check/check-fabricated-docs.mjs — 0 production-code change, so all +5 is inherited contributor drift. No coverage/openapi/i18n regressions.", "_rebaseline_2026_06_22_v3834_release": "v3.8.34 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR→release fast-gates, so warnings accrued across this cycle's parallel-session merges — #4583-4586/#4588-4593/#4606-4621/#4644/#4647/#4696/etc.). 3900→3907 (+7). Verified my release-finalize working tree touches ONLY CHANGELOG.md (git status: 0 code changes), so all +7 is inherited contributor drift. No coverage/openapi/i18n regressions.", @@ -96,19 +97,22 @@ "eps": 0.5 }, "deadExports": { - "value": 345, + "value": 346, "direction": "down", "dedicatedGate": true, + "_rebaseline_2026_06_27_v3838_release": "345->346 (+1). v3.8.38 cycle drift surfaced by the release-green pre-flight (Quality Ratchet does NOT run on PR->release fast-gates). Net +1 inherited from this cycle's feature/fix merges (new executors/providers, compression fidelity-gate module) minus #5138's removal of dead legacy store modules. Release-finalize working tree touches ONLY CHANGELOG.md + i18n mirrors + README + baselines — 0 production-code change. Structural cleanup tracked as debt.", "_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle." }, "cognitiveComplexity": { - "value": 826, + "value": 841, "direction": "down", "dedicatedGate": true, + "_rebaseline_2026_06_27_v3838_release": "833->841 (+8). v3.8.38 cycle drift surfaced by the release-green pre-flight (cognitive-complexity does NOT run on PR->release fast-gates). Inherited drift from this cycle's ~78 feature/fix merges (compression fidelity-gate #5143, SSE/streaming hardening #5124/#5108/#5085, resilience #5093, quota keepalive #5102, contributor provider/translator branches). god-file decomposition #3501 is complexity-neutral. Release-finalize working tree touches ONLY CHANGELOG.md + i18n mirrors + README + baselines — 0 production-code change. Structural shrink tracked in #3501.", "_rebaseline_2026_06_25_v3836_release": "801→816 (+15) — v3.8.36 cycle drift surfaced by the fix-PR #5029 CI (cognitive-complexity does NOT run on PR→release fast-gates, and the Quality Ratchet job was SKIPPED on the release PR #4854 itself, so the +15 from this cycle's 137 commits accrued unmeasured). Measured locally = 816 (identical to the CI Cognitive complexity ratchet). Drift from legit cycle features (Quota-Share Fase 2/3, task-aware/Fusion combo, contributor provider/translator branches); god-file decomposition #3501 is complexity-neutral. This fix-PR touches only pack-artifact-policy.ts + a test + the 2 baseline JSONs — contributes 0. Structural shrink tracked in #3501.", "_rebaseline_2026_06_22_v3834_release": "797→801 (+4) — v3.8.34 cycle drift surfaced by the release-green pre-flight (cognitive-complexity does NOT run on PR→release fast-gates). Verified my release-finalize working tree touches ONLY CHANGELOG.md (git status: 0 code changes), so all +4 is inherited contributor drift from this cycle's parallel-session merges. Structural shrink tracked in #3501.", "_rebaseline_2026_06_22_v3833_release": "793→797 (+4) — pre-existing cycle drift on origin/release/v3.8.33.", - "_rebaseline_2026_06_26_v3837_release": "816->826. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle." + "_rebaseline_2026_06_26_v3837_release": "816->826. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.", + "_rebaseline_2026_06_26_v3838_release": "826->833. v3.8.38 release base measures 833 locally on origin/release/v3.8.38 (800b04ad6) while the committed baseline still says 826. This PR measures the same 833 after refactoring jsonToSse helpers back under the sonarjs/cognitive-complexity threshold, so it does not add a net cognitive-complexity violation. The baseline bump records inherited release-base drift only; structural shrink remains tracked by the existing chatCore decomposition work." }, "typeCoveragePct": { "value": 92.17, diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 29b2f7b4a8..a87c5fd10d 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 16907c9875..f6a59d1f00 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 16907c9875..f6a59d1f00 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index a290218473..1117b29c82 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index ab66a42906..34fb4aa1f7 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 7224e25c46..f9052495a6 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index b0936c06cd..c74c87f577 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 6e4d87c5eb..b188727564 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 2ee6881a4e..c6073dc418 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index f91ca4bbfc..7648a03191 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index a94afc017f..bdfdd21c85 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index 7c24e33244..926fa1458e 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index 336debb7fc..c8cb8ca638 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 0e1821a90a..2ceaaf874b 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 8d4f23d490..4f107e1d5f 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 208f8983e6..50b3a8b32a 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 33ab8b744f..3e31079f82 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index f18d7baad7..d9878b3259 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 3d621f9b94..e42a5fa0ee 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 857c13825a..e93c7c9e69 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index ff090cacbd..6397b76ae4 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index dcb29dd631..8b338825a7 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index a3daac861b..bb5a43ad52 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index db6f40b4a5..699cc30f95 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index 673bba6927..58d31174d2 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 95439e9401..e4dd29ca8b 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 988df79449..85573cf780 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index a696d04e63..9d9ecf6ddf 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index 7f2f882c99..bd2ff9b6ac 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index e87ce53eb8..c862f6dc42 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 96c9003f29..d42e147854 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 8af7823f12..f3ff7f0f68 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index b5900960f7..136bb5a938 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 5dbddc7f1c..b6d51ed79d 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 8a8a1f3d5c..0b877341ab 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 85a2479c70..4d0b06c33f 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index eabb512eb8..58d80261ad 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index b3192c2bf3..6d185fa140 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 71b76d76f3..2b0a5631d6 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 5b4cac11d1..4e556bc2c2 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 337bb1b3ae..6515f55ace 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -6,6 +6,87 @@ ## [3.8.31] — 2026-06-20 +## [3.8.38] — 2026-06-27 + +### ✨ New Features + +- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23) +- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari) +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) +- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) +- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) +- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) +- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) +- **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) +- **feat(proxy):** support auth-less `host:port` batch import and surface proxy-test failures. (thanks @dimaslanjaka) +- **feat(video): Alibaba DashScope video provider (`wan2.7-t2v`)** — adds the `alibaba` video provider (DashScope async task → poll → MP4) wired through the standard apikey credential path, so text-to-video requests can route to Alibaba's `wan2.7-t2v` model. (thanks @josevictorferreira) +- **feat(cc): per-connection "summarized thinking display" toggle for Claude-Code-compatible providers** — exposes a connection-level toggle that drives the existing Copilot summarized-thinking marker, so operators can opt a CC-compatible connection into summarized reasoning display from the UI (schema + request defaults + provider modals, with i18n). (thanks @rdself) +- **feat(compression): compression playground in the studio (Play + Compare tabs)** — `/dashboard/compression/studio` gains a synthetic playground: paste text → per-engine **lanes** (each deterministic engine run alone via `/api/compression/preview`) plus a **combined waterfall** ordered by `stackPriority`, and a free **A/B Compare** grid with on-demand, **USD-capped** fidelity verdicts (`/api/compression/compare` + `compare/verify`). The preview route now uses the real cl100k tokenizer, returns `engineBreakdown`, and accepts an ordered `pipeline[]`; new `compare` / `compare/verify` / `retrieve` routes; the live WS feed moved to `/dashboard/compression/live`. Management-only. ([#5080](https://github.com/diegosouzapw/OmniRoute/pull/5080)) +- **feat(dashboard): expose Fusion `judgeModel` + `fusionTuning` in the combo editor** — the Fusion strategy editor now surfaces the judge model (synthesizes the panel answers; defaults to the first panel model) plus the quorum-grace tuning fields (`minPanel`, `stragglerGraceMs`, `panelHardTimeoutMs`) that `open-sse/services/fusion.ts` already reads. Schema-validated + bounded; empty tuning is never persisted. ([#5074](https://github.com/diegosouzapw/OmniRoute/pull/5074)) +- **feat(compression): opt-in per-step fidelity gate for the stacked pipeline** — each compression step can now be guarded by a pure fidelity checker (4 invariants, fail-open) so a lossy engine that would degrade the prompt past a threshold is rejected and its lane skipped instead of silently shipping. Configurable via `fidelityGate` (advanced thresholds intentionally API-omitted), with a per-lane rejection breakdown surfaced in the studio playground toggle. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass)** — the session-dedup engine gains a second fuzzy pass that collapses near-duplicate (not just byte-identical) segments, with a playground toggle to compare on/off. ([#5143](https://github.com/diegosouzapw/OmniRoute/pull/5143)) +- **feat(quota): opt-in Codex/Claude auto-ping keepalive** — an opt-in background keepalive can periodically ping Codex/Claude connections to keep their session/quota state warm, reducing cold-start failures on the first real request. ([#5102](https://github.com/diegosouzapw/OmniRoute/pull/5102)) +- **feat(ops): SRE playbooks + ops helper scripts** — salvaged from a closed stale PR; adds operator runbooks and ops helper scripts. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **feat(mcp): web-session robustness — cookie dedup + browser-pool observability** — the MCP web-session path now de-duplicates cookies when (re)hydrating a session (avoiding conflicting duplicate `Cookie` headers) and exposes browser-pool observability (pool size / in-use / acquisition metrics) for the headless web providers. ([#5121](https://github.com/diegosouzapw/OmniRoute/pull/5121), builds on [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368)) + +### 🔧 Bug Fixes + +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) +- **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) +- **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(kiro): retire `claude-sonnet-4.5` from the Kiro catalog + pin the exact Kiro 400 error** — `claude-sonnet-4.5` left the Kiro free-tier lineup (current active models: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), so it is removed from the Kiro registry entry and the free-model catalog. A regression test now pins Kiro's verbatim `[400] Invalid model. Please select a different model to continue.` to the `isModelUnavailableError` model-unavailable classification. A 400 on every model (including current ones) points to a server-side Kiro tier/region gate, not an OmniRoute catalog bug. ([#5140](https://github.com/diegosouzapw/OmniRoute/pull/5140), closes [#4484](https://github.com/diegosouzapw/OmniRoute/issues/4484)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) +- **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) +- **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) +- **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) +- **fix(usage): dedupe request-usage logging and debounce stats events** — `saveRequestUsage` now guards against duplicate inserts (natural key: timestamp + provider + model + connection + api-key + token counts), back-fills a missing `endpoint`, and only emits `usageRecorded` when a row was actually inserted; stats `update`/`pending` event bursts are collapsed into a single debounced notification to reduce churn. ([#4940](https://github.com/diegosouzapw/OmniRoute/pull/4940) — thanks @nguyenxvotanminh3) +- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler** — `contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa) +- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English** — `[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103)) +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) +- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) +- **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) +- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) +- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) +- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) +- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) +- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub) +- **fix(translator):** preserve client `cache_control` breakpoints when routing Claude-format requests (e.g. Claude Code) to Alibaba DashScope's OpenAI-compatible providers (`alibaba` / `alibaba-cn`). The Claude→OpenAI translation previously stripped the markers from the system and message text blocks, so DashScope's explicit caching never engaged and every request was a cache miss. Cache hints now survive when preservation is requested for caching-capable OpenAI-format providers. (thanks @sacrtap) +- **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) +- **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) +- **fix(translator):** forward image `tool_result` blocks as `image_url` instead of stringifying base64. (thanks @alican532) +- **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. ([#5086](https://github.com/diegosouzapw/OmniRoute/pull/5086) — thanks @herjarsa) +- **fix(models): derive model-discovery config from the registry `modelsUrl`** — providers absent from the hardcoded `PROVIDER_MODELS_CONFIG` but carrying a registry `modelsUrl` (e.g. MiniMax) now get an auto-derived Bearer `/v1/models` discovery config, so "discover models" works instead of returning nothing. (thanks @herjarsa) +- **fix(compression): resolve worker + rule/filter assets via runtime anchors (standalone bundle)** — the LLMLingua worker and the RTK rule/filter loaders relied on `fileURLToPath(import.meta.url)`, which the standalone bundle freezes to the build-machine path, so the worker never spawned and rule/filter packs failed to resolve. They now anchor on `process.cwd()`/`argv[1]` (with `pathToFileURL` for the worker URL). (thanks @fulorgnas) +- **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) +- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). +- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. ([#5122](https://github.com/diegosouzapw/OmniRoute/pull/5122)) +- **fix(providers): add MiniMax M3 & Nemotron 3 Ultra to the Cline catalog** — the two models were missing from Cline's provider catalog and could not be selected; both are now registered. ([#5136](https://github.com/diegosouzapw/OmniRoute/pull/5136), closes [#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321)) +- **fix(dashboard): key model-visibility toggle on the canonical `providerId`** — the per-model visibility toggle keyed off a display id, so toggling a model on one provider alias could mis-target another; it now keys on the canonical `providerId`. ([#5091](https://github.com/diegosouzapw/OmniRoute/pull/5091) — thanks @Theadd) +- **fix(diagnostics): recognize the Claude API format in `detectMalformedNonStream`** — salvaged null-guard so a Claude-shaped non-streaming body is no longer misclassified. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @herjarsa / @diegosouzapw) +- **fix(logging): track the final connection IDs in failover logs** — failover log lines now record the connection that actually served (or last failed) the request, instead of only the first attempt. ([#5016](https://github.com/diegosouzapw/OmniRoute/pull/5016) — thanks @JxnLexn) +- **fix(sse): ignore disconnect races during in-band stream error handling** — a client disconnect that races with in-band upstream error handling no longer surfaces as a spurious provider failure. ([#5007](https://github.com/diegosouzapw/OmniRoute/pull/5007) — thanks @JxnLexn) +- **fix(dashboard): surface the server error on `handleToggleCombo` failure** — a failed combo toggle now shows the backend error instead of silently no-op'ing. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @KooshaPari / @diegosouzapw) +- **fix(quota): track provider quota reset windows + enrich the Codex playground** — observed quota reset windows are tracked and surfaced, and the Codex playground gains the enriched quota metadata. ([#5141](https://github.com/diegosouzapw/OmniRoute/pull/5141) — thanks @Witroch4 / @diegosouzapw) +- **fix(sidebar): drop the orphan `settings` accent color** — removed a dangling accent-color entry that broke `typecheck:core`. ([#5142](https://github.com/diegosouzapw/OmniRoute/pull/5142)) + +### 🔒 Security + +- **fix(security): exact-host Anthropic `baseUrl` check** — the Anthropic base-URL guard used a substring match that a crafted host could partially satisfy; it now requires an exact host match (resolves CodeQL `js/incomplete-url-substring-sanitization` alert #674). ([#5130](https://github.com/diegosouzapw/OmniRoute/pull/5130)) + +### 📝 Maintenance + +- **refactor(store): remove dead legacy store modules** — salvaged cleanup of unused legacy store code. ([#5138](https://github.com/diegosouzapw/OmniRoute/pull/5138) — thanks @JxnLexn / @diegosouzapw) +- **test(combo): deterministic routing-decision matrix for all 17 strategies** — a deterministic E2E matrix pins the routing decision of every combo strategy. ([#5146](https://github.com/diegosouzapw/OmniRoute/pull/5146)) +- **chore:** baseline reconciliations (complexity / file-size / cognitive), golden-snapshot + apikey-count alignment for new providers, orphan-test relocation, release base-red repairs, CHANGELOG i18n mirror sync, and an `actions/cache` 5→6 bump. ([#5145](https://github.com/diegosouzapw/OmniRoute/pull/5145), [#5144](https://github.com/diegosouzapw/OmniRoute/pull/5144), [#5125](https://github.com/diegosouzapw/OmniRoute/pull/5125), [#5126](https://github.com/diegosouzapw/OmniRoute/pull/5126), [#5120](https://github.com/diegosouzapw/OmniRoute/pull/5120), [#5117](https://github.com/diegosouzapw/OmniRoute/pull/5117), [#5112](https://github.com/diegosouzapw/OmniRoute/pull/5112)) + +--- + ## [3.8.37] — 2026-06-26 ### ✨ New Features diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e9fa9c2949..824d04a138 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.37 + version: 3.8.38 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/docs/ops/RELEASE_CHECKLIST.md b/docs/ops/RELEASE_CHECKLIST.md index ab9c37032b..4821fa77fa 100644 --- a/docs/ops/RELEASE_CHECKLIST.md +++ b/docs/ops/RELEASE_CHECKLIST.md @@ -74,6 +74,9 @@ npm run test:e2e # optional but recommended - [ ] `npm run test:vitest` — pass (MCP server, autoCombo, cache) - [ ] `npm run test:coverage` — gate 75/75/75/70 satisfied (statements/lines/functions/branches) - [ ] `npm run test:integration` — pass (if changes touch DB / handlers) +- [ ] `npm run test:combo:matrix` — pass (combo strategy matrix: proves all 17 routing strategies' selection decisions deterministically; run when touching combo routing, strategy resolution, or fallback logic) +- [ ] `RUN_COMBO_LIVE=1 npm run test:combo:live` — **optional/manual** (gated real-upstream smoke; sources a read-only DB snapshot from VPS `root@192.168.0.15`; hits real providers, costs credits; never runs in CI; skips cleanly without the gate) +- [ ] `npm run test:combo:live:vps` — **optional/manual** (Phase-3 VPS live smoke: 7 HTTP scenarios against the live `.15` server via plain Node ESM; requires `ssh root@192.168.0.15`; creates/deletes only `__live_test__*` combos; hits real providers; never runs in CI) - [ ] `npm run test:e2e` — pass (UI changes) - [ ] `npm run test:protocols:e2e` — pass (MCP/A2A changes) - [ ] `npm run test:ecosystem` — pass diff --git a/docs/providers/AGENTROUTER.md b/docs/providers/AGENTROUTER.md index 7130cc53d8..8e143bd516 100644 --- a/docs/providers/AGENTROUTER.md +++ b/docs/providers/AGENTROUTER.md @@ -121,16 +121,17 @@ provider. For reference, the cc-compatible bridge sends the following on each upstream request (see `open-sse/services/claudeCodeCompatible.ts`): -| Header | Value | -| ------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `Authorization` | `Bearer ` | -| `User-Agent` | `claude-cli/2.1.187 (external, sdk-cli)` | -| `anthropic-version` | `2023-06-01` | -| `anthropic-beta` | `claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24` | -| Per-connection redact-thinking beta toggle | Adds `redact-thinking-2026-02-12` for upstreams that specifically require redacted thinking streams | -| `anthropic-dangerous-direct-browser-access` | `true` | -| `x-app` | `cli` | -| `X-Stainless-*` | Various Stainless SDK headers (lang, package version, OS, arch, etc.) | +| Header | Value | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `Authorization` | `Bearer ` | +| `User-Agent` | `claude-cli/2.1.187 (external, sdk-cli)` | +| `anthropic-version` | `2023-06-01` | +| `anthropic-beta` | `claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24` | +| Per-connection redact-thinking beta toggle | Adds `redact-thinking-2026-02-12` for upstreams that specifically require redacted thinking streams | +| Per-connection summarized thinking toggle | Adds `display: "summarized"` to CC Compatible thinking requests that did not already set a display mode | +| `anthropic-dangerous-direct-browser-access` | `true` | +| `x-app` | `cli` | +| `X-Stainless-*` | Various Stainless SDK headers (lang, package version, OS, arch, etc.) | This is what allows requests to pass the upstream WAF / client whitelist. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 5fef70fbac..6008d3f8c6 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -179,6 +179,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `CORS_ORIGIN` | `*` | Next.js middleware | CORS `Access-Control-Allow-Origin` value. Restrict for production. | | `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. | | `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, 10.x.x.x, etc.). **REQUIRED for self-hosted providers** (LM Studio, Ollama, vLLM, Llamafile, Triton, SearXNG). When `false`, the dashboard rejects validation of local URLs. | +| `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | `true` | `src/shared/network/outboundUrlGuard.ts` | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN, private ranges) — scoped to the provider validation path. **Default `true`** (local-first); set `false` to enforce strict public-only blocking. Cloud-metadata endpoints (169.254.169.254, metadata.google.internal) stay blocked regardless. (#5066) | ### Hardening Checklist @@ -294,6 +295,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con | `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). | | `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. | | `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS` | `32` | `open-sse/utils/proxyDispatcher.ts` | Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher. Long-lived SSE streams such as Codex `/v1/responses` need more than one connection when several requests share the same account-level proxy. Values above `256` are capped. | +| `SOCKS_HANDSHAKE_TIMEOUT_MS` | `10000` | `open-sse/utils/socksConnectorWithFamily.ts` | SOCKS5 handshake (connect) timeout in ms. Raise it when a single residential gateway host is hit by high concurrency (e.g. 100 simultaneous requests) — the real handshake can exceed 10s under a saturated pool even though the proxy is reachable, which otherwise surfaces as a false `[Proxy Fast-Fail] Proxy unreachable`. Capped at `120000`. | | `PROXY_FAIL_OPEN` | `false` | `src/sse/handlers/chatHelpers.ts` | When `false` (default), a request whose assigned proxy fails to resolve is **refused (fail-closed)** rather than falling back to a direct connection — prevents real-IP leaks. Set `true` to restore the legacy DIRECT fallback. | | `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. | | `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors. | diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 5da6146a0a..bb6c0b2fe1 100644 --- a/docs/reference/FEATURE_FLAGS.md +++ b/docs/reference/FEATURE_FLAGS.md @@ -61,7 +61,7 @@ used when neither a DB override nor an environment variable is present. | `PII_RESPONSE_SANITIZATION_MODE` | enum | `redact` | Mode for PII response sanitization. Values: `redact`, `warn`, `block`, `off`. | | `OUTBOUND_SSRF_GUARD_ENABLED` | boolean | `true` | Block outbound requests to private/internal IP ranges. | -### Network (7) +### Network (8) | Key | Type | Default | Restart | Description | | ----------------------------------------------- | ------- | ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -71,6 +71,7 @@ used when neither a DB override nor an environment variable is present. | `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | boolean | `false` | | Allow OAuth and provider validation flows to bypass a pinned proxy and connect directly when proxy reachability pre-checks fail. Off by default because this can change egress IP. | | `MITM_DISABLE_TLS_VERIFY` | boolean | `false` | ✓ | Disable TLS certificate verification for the MITM proxy. **Danger.** | | `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | boolean | `false` | | Allow provider URLs pointing to private/internal networks. | +| `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | boolean | `true` | | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN). On by default (local-first); disable for strict public-only blocking. Cloud-metadata stays blocked. | | `ENABLE_CC_COMPATIBLE_PROVIDER` | boolean | `false` | ✓ | Enable Claude Code compatible provider mode. | ### Policies (3) diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 07699741c0..de4bd5d90e 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -33,281 +33,282 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each ## OAuth Providers (19) -| ID | Alias | Name | Tags | Website | Notes | -|----|-------|------|------|---------|-------| -| `agy` | `agy` | Antigravity CLI | OAuth | [link](https://antigravity.google) | Import your Antigravity CLI (`agy`) login (paste/upload its token file), auto-detect a local CLI login, or sign in with Google. Shares the Antigravity backend (incl. Claude models). | -| `amazon-q` | `aq` | Amazon Q | OAuth | [link](https://aws.amazon.com/q/developer/) | Uses the same AWS Builder ID or imported refresh-token flow as Kiro, but keeps Amazon Q connections separate. | -| `antigravity` | — | Antigravity | OAuth | — | — | -| `claude` | `cc` | Claude Code | OAuth | — | — | -| `cline` | `cl` | Cline | OAuth | — | — | -| `codex` | `cx` | OpenAI Codex | OAuth | — | — | -| `cursor` | `cu` | Cursor IDE | OAuth | — | — | -| `devin-cli` | `dv` | Devin CLI (Official) | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai | -| `gemini-cli` | `gemini-cli` | Gemini CLI | OAuth | — | Uses Gemini CLI OAuth / Cloud Code credentials. Pro models require an eligible Google account or paid plan. | -| `github` | `gh` | GitHub Copilot | OAuth | — | — | -| `gitlab-duo` | `gitlab-duo` | GitLab Duo | OAuth | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance. | -| `kilocode` | `kc` | Kilo Code | OAuth | — | — | -| `kimi-coding` | `kmc` | Kimi Coding | OAuth | — | — | -| `kiro` | `kr` | Kiro AI | OAuth | — | Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use. | -| `qoder` | `if` | Qoder AI | OAuth | — | — | -| `qwen` | `qw` | Qwen Code | OAuth | — | ⚠️ **DEPRECATED.** Qwen OAuth free tier was discontinued on 2026-04-15. Use 'bailian-coding-plan', 'alibaba', 'alibaba-cn', or 'openrouter' provider with API key instead. | -| `trae` | `tr` | Trae | OAuth | [link](https://trae.ai) | Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT ', ~14-day lifetime) as the access token; web_id/biz_user_id/user_unique_id/scope/tenant/region propagate via providerSpecificData. No headless refresh for pasted tokens — re-paste on expiry. | -| `windsurf` | `ws` | Windsurf (Devin CLI) | OAuth | [link](https://windsurf.com) | In the Windsurf / VS Code IDE, open the command palette and run `Windsurf: Provide Auth Token` (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it here. Note: opening windsurf.com/show-auth-token directly only renders a "Redirecting" page — the IDE must initiate the flow (it adds a `?state=...` param) for the token to appear. | -| `zed` | `zd` | Zed IDE | OAuth | [link](https://zed.dev) | Zed stores LLM provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) in the OS keychain. Use the Import button below to discover and import them automatically. | +| ID | Alias | Name | Tags | Website | Notes | +| ------------- | ------------ | -------------------- | ----- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agy` | `agy` | Antigravity CLI | OAuth | [link](https://antigravity.google) | Import your Antigravity CLI (`agy`) login (paste/upload its token file), auto-detect a local CLI login, or sign in with Google. Shares the Antigravity backend (incl. Claude models). | +| `amazon-q` | `aq` | Amazon Q | OAuth | [link](https://aws.amazon.com/q/developer/) | Uses the same AWS Builder ID or imported refresh-token flow as Kiro, but keeps Amazon Q connections separate. | +| `antigravity` | — | Antigravity | OAuth | — | — | +| `claude` | `cc` | Claude Code | OAuth | — | — | +| `cline` | `cl` | Cline | OAuth | — | — | +| `codex` | `cx` | OpenAI Codex | OAuth | — | — | +| `cursor` | `cu` | Cursor IDE | OAuth | — | — | +| `devin-cli` | `dv` | Devin CLI (Official) | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai | +| `gemini-cli` | `gemini-cli` | Gemini CLI | OAuth | — | Uses Gemini CLI OAuth / Cloud Code credentials. Pro models require an eligible Google account or paid plan. | +| `github` | `gh` | GitHub Copilot | OAuth | — | — | +| `gitlab-duo` | `gitlab-duo` | GitLab Duo | OAuth | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance. | +| `kilocode` | `kc` | Kilo Code | OAuth | — | — | +| `kimi-coding` | `kmc` | Kimi Coding | OAuth | — | — | +| `kiro` | `kr` | Kiro AI | OAuth | — | Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use. | +| `qoder` | `if` | Qoder AI | OAuth | — | — | +| `qwen` | `qw` | Qwen Code | OAuth | — | ⚠️ **DEPRECATED.** Qwen OAuth free tier was discontinued on 2026-04-15. Use 'bailian-coding-plan', 'alibaba', 'alibaba-cn', or 'openrouter' provider with API key instead. | +| `trae` | `tr` | Trae | OAuth | [link](https://trae.ai) | Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT ', ~14-day lifetime) as the access token; web_id/biz_user_id/user_unique_id/scope/tenant/region propagate via providerSpecificData. No headless refresh for pasted tokens — re-paste on expiry. | +| `windsurf` | `ws` | Windsurf (Devin CLI) | OAuth | [link](https://windsurf.com) | In the Windsurf / VS Code IDE, open the command palette and run `Windsurf: Provide Auth Token` (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it here. Note: opening windsurf.com/show-auth-token directly only renders a "Redirecting" page — the IDE must initiate the flow (it adds a `?state=...` param) for the token to appear. | +| `zed` | `zd` | Zed IDE | OAuth | [link](https://zed.dev) | Zed stores LLM provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) in the OS keychain. Use the Import button below to discover and import them automatically. | ## Web Cookie Providers (22) -| ID | Alias | Name | Tags | Website | Notes | -|----|-------|------|------|---------|-------| -| `adapta-web` | `adp-web` | Adapta.org (Adapta One Web) | Web cookie | [link](https://agent.adapta.one) | Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies) | -| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your __Secure-authjs.session-token value or full cookie header from app.blackbox.ai | -| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your __Secure-next-auth.session-token cookie value from chatgpt.com | -| `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | -| `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste your access_token from copilot.microsoft.com (or export a .har file from DevTools while logged in) | -| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken | -| `doubao-web` | `db` | Doubao Web (ByteDance) | Web cookie | [link](https://www.doubao.com) | Paste your session cookie from doubao.com (DevTools → Application → Cookies) | -| `gemini-business` | `gembiz` | Gemini Business (Enterprise) | Web cookie | [link](https://business.gemini.google) | From your enterprise account: open business.gemini.google/home/cid/{your-cid}, then copy __Secure-1PSID and __Secure-1PSIDTS cookies from DevTools → Application → Cookies. Paste as a cookie header below. | -| `gemini-web` | `gweb` | Gemini Web (Free) | Web cookie | [link](https://gemini.google.com) | Paste your __Secure-1PSID cookie value from gemini.google.com. Optionally add __Secure-1PSIDTS separated by semicolon. | -| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste the full grok.com cookie line from DevTools → Application → Cookies. Include both `sso` and `sso-rw` (e.g. `sso=...; sso-rw=...`) — Grok's anti-bot rejects `sso` on its own. | -| `huggingchat` | `huggingchat` | HuggingChat (Free) | Web cookie | [link](https://huggingface.co/chat) | Paste your hf-chat cookie value from huggingface.co/chat (DevTools → Application → Cookies → hf-chat). Optional — works without auth for basic use. | -| `inner-ai` | `in-ai` | Inner.ai (Subscription) | Web cookie | [link](https://app.innerai.com) | Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com | -| `kimi-web` | `kimi-web` | Kimi Web (Moonshot AI) | Web cookie | [link](https://kimi.moonshot.cn) | Paste your session cookie from kimi.moonshot.cn (DevTools → Application → Cookies) | -| `lmarena` | `lma` | LMArena (Free) | Web cookie | [link](https://lmarena.ai) | Paste the full Cookie header from lmarena.ai (DevTools → Network → request → Cookie). The session is now split across arena-auth-prod-v1.0, .1, … — copy the whole header. Optional — works with free tier for basic comparisons. | -| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your abra_sess value or full cookie header from meta.ai | -| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your __Secure-next-auth.session-token cookie value from perplexity.ai | -| `phind` | `ph` | Phind (Free) | Web cookie | [link](https://www.phind.com) | ⚠️ **DEPRECATED.** Phind shut down its API (2026-01); the /api/chat endpoint no longer serves (sweep 2026-06-19). | -| `poe-web` | `poe` | Poe Web (Subscription) | Web cookie | [link](https://poe.com) | Paste your p-b cookie value from poe.com (DevTools → Application → Cookies → p-b) | -| `qwen-web` | `qwen-web` | Qwen Web (Free) | Web cookie | [link](https://chat.qwen.ai) | Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token). | -| `t3-web` | `t3chat` | t3.chat (Pro/Free) | Web cookie | [link](https://t3.chat) | Open t3.chat in your browser, log in, then open DevTools → Application → Local Storage → https://t3.chat. Copy the value of 'convex-session-id'. Also open DevTools → Network, copy the Cookie header from any request. Paste both values here. See provider setup docs for a step-by-step guide. | -| `v0-vercel-web` | `v0` | v0 Vercel Web (Code Gen) | Web cookie | [link](https://v0.dev) | Paste your session cookie from v0.dev (DevTools → Application → Cookies) | -| `venice-web` | `ven` | Venice Web (Privacy) | Web cookie | [link](https://venice.ai) | Paste your session cookie from venice.ai (DevTools → Application → Cookies) | +| ID | Alias | Name | Tags | Website | Notes | +| ----------------- | ------------- | ---------------------------- | ---------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `adapta-web` | `adp-web` | Adapta.org (Adapta One Web) | Web cookie | [link](https://agent.adapta.one) | Paste your \_\_client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies) | +| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your \_\_Secure-authjs.session-token value or full cookie header from app.blackbox.ai | +| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your \_\_Secure-next-auth.session-token cookie value from chatgpt.com | +| `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | +| `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste your access_token from copilot.microsoft.com (or export a .har file from DevTools while logged in) | +| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken | +| `doubao-web` | `db` | Doubao Web (ByteDance) | Web cookie | [link](https://www.doubao.com) | Paste your session cookie from doubao.com (DevTools → Application → Cookies) | +| `gemini-business` | `gembiz` | Gemini Business (Enterprise) | Web cookie | [link](https://business.gemini.google) | From your enterprise account: open business.gemini.google/home/cid/{your-cid}, then copy **Secure-1PSID and **Secure-1PSIDTS cookies from DevTools → Application → Cookies. Paste as a cookie header below. | +| `gemini-web` | `gweb` | Gemini Web (Free) | Web cookie | [link](https://gemini.google.com) | Paste your **Secure-1PSID cookie value from gemini.google.com. Optionally add **Secure-1PSIDTS separated by semicolon. | +| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste the full grok.com cookie line from DevTools → Application → Cookies. Include both `sso` and `sso-rw` (e.g. `sso=...; sso-rw=...`) — Grok's anti-bot rejects `sso` on its own. | +| `huggingchat` | `huggingchat` | HuggingChat (Free) | Web cookie | [link](https://huggingface.co/chat) | Paste your hf-chat cookie value from huggingface.co/chat (DevTools → Application → Cookies → hf-chat). Optional — works without auth for basic use. | +| `inner-ai` | `in-ai` | Inner.ai (Subscription) | Web cookie | [link](https://app.innerai.com) | Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com | +| `kimi-web` | `kimi-web` | Kimi Web (Moonshot AI) | Web cookie | [link](https://kimi.moonshot.cn) | Paste your session cookie from kimi.moonshot.cn (DevTools → Application → Cookies) | +| `lmarena` | `lma` | LMArena (Free) | Web cookie | [link](https://lmarena.ai) | Paste the full Cookie header from lmarena.ai (DevTools → Network → request → Cookie). The session is now split across arena-auth-prod-v1.0, .1, … — copy the whole header. Optional — works with free tier for basic comparisons. | +| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your abra_sess value or full cookie header from meta.ai | +| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your \_\_Secure-next-auth.session-token cookie value from perplexity.ai | +| `phind` | `ph` | Phind (Free) | Web cookie | [link](https://www.phind.com) | ⚠️ **DEPRECATED.** Phind shut down its API (2026-01); the /api/chat endpoint no longer serves (sweep 2026-06-19). | +| `poe-web` | `poe` | Poe Web (Subscription) | Web cookie | [link](https://poe.com) | Paste your p-b cookie value from poe.com (DevTools → Application → Cookies → p-b) | +| `qwen-web` | `qwen-web` | Qwen Web (Free) | Web cookie | [link](https://chat.qwen.ai) | Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token). | +| `t3-web` | `t3chat` | t3.chat (Pro/Free) | Web cookie | [link](https://t3.chat) | Open t3.chat in your browser, log in, then open DevTools → Application → Local Storage → https://t3.chat. Copy the value of 'convex-session-id'. Also open DevTools → Network, copy the Cookie header from any request. Paste both values here. See provider setup docs for a step-by-step guide. | +| `v0-vercel-web` | `v0` | v0 Vercel Web (Code Gen) | Web cookie | [link](https://v0.dev) | Paste your session cookie from v0.dev (DevTools → Application → Cookies) | +| `venice-web` | `ven` | Venice Web (Privacy) | Web cookie | [link](https://venice.ai) | Paste your session cookie from venice.ai (DevTools → Application → Cookies) | ## API Key Providers (paid / paid-with-free-credits) (157) -| ID | Alias | Name | Tags | Website | Notes | -|----|-------|------|------|---------|-------| -| `360ai` | `360ai` | 360 AI | API key | [link](https://ai.360.cn) | Get API key at ai.360.cn | -| `agentrouter` | `agentrouter` | AgentRouter | API key, aggregator | [link](https://agentrouter.org) | $200 free credits on signup - multi-model routing gateway | -| `ai21` | `ai21` | AI21 Labs | API key | [link](https://www.ai21.com) | $10 trial credits on signup (valid 3 months), no credit card required | -| `aimlapi` | `aiml` | AI/ML API | API key, aggregator | [link](https://aimlapi.com) | Free tier paused (2026) — AI/ML API is now pay-as-you-go only (min $20 top-up); no recurring free credits. | -| `alibaba` | `ali` | Alibaba | API key | [link](https://dashscope-intl.aliyuncs.com) | — | -| `alibaba-cn` | `ali-cn` | Alibaba (China) | API key | [link](https://dashscope.aliyuncs.com) | — | -| `anthropic` | `anthropic` | Anthropic | API key | [link](https://platform.claude.com) | — | -| `api-airforce` | `af` | Api.airforce | API key | [link](https://api.airforce) | 55 free tier models including Grok-3, Claude 3.7, Qwen3, Kimi-K2, Gemini 2.5 Flash, DeepSeek-V3 | -| `arcee-ai` | `arcee` | Arcee AI | API key | [link](https://arcee.ai) | Get API key at arcee.ai | -| `azure-ai` | `azure-ai` | Azure AI Foundry | API key, enterprise | [link](https://learn.microsoft.com/azure/ai-foundry) | Use your Azure AI Foundry key. Base URL can be https://.services.ai.azure.com/openai/v1/ or https://.openai.azure.com/openai/v1/. | -| `azure-openai` | `azure` | Azure OpenAI | API key, enterprise | [link](https://azure.microsoft.com/products/ai-services/openai-service) | Use your Azure OpenAI API key. Base URL should be your resource endpoint, for example https://my-resource.openai.azure.com. | -| `baichuan` | `baichuan` | Baichuan | API key | [link](https://baichuan.com) | Get API key at platform.baichuan-ai.com | -| `baidu` | `baidu` | Baidu (ERNIE) | API key | [link](https://yiyan.baidu.com) | Get API key at console.bce.baidu.com | -| `bailian-coding-plan` | `bcp` | Alibaba Coding Plan | API key | [link](https://www.alibabacloud.com/help/en/model-studio/coding-plan) | — | -| `baseten` | `baseten` | Baseten | API key | [link](https://baseten.co) | $30 free trial credits for GPU inference | -| `bazaarlink` | `bzl` | BazaarLink | API key | [link](https://bazaarlink.ai) | Free tier with auto:free routing — zero-cost inference, no credit card required | -| `bedrock` | `bedrock` | Amazon Bedrock | API key, enterprise | [link](https://aws.amazon.com/bedrock) | Use your Amazon Bedrock API key and configure the AWS region where your models are enabled (for example eu-west-2). OmniRoute calls Bedrock's native Converse API directly. | -| `black-forest-labs` | `bfl` | Black Forest Labs | API key, image | [link](https://blackforestlabs.ai) | — | -| `blackbox` | `bb` | Blackbox AI | API key | [link](https://blackbox.ai) | Free tier: unlimited basic chat plus Minimax-M2.5, no credit card required | -| `bluesminds` | `bm` | BluesMinds | API key | [link](https://www.bluesminds.com) | Free daily pi credits — supports 200+ models including GPT-4o, GPT-4.1, Claude Sonnet 4.5, Gemini 2.0 Flash, DeepSeek V4, Qwen, Kimi K2 | -| `byteplus` | `bpm` | BytePlus ModelArk | API key | [link](https://console.byteplus.com/ark) | — | -| `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks | -| `cablyai` | `cablyai` | CablyAI | API key, aggregator | [link](https://cablyai.com) | Bearer API key for the CablyAI OpenAI-compatible gateway. | -| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card. | -| `chutes` | `chutes` | Chutes.ai | API key, aggregator | [link](https://chutes.ai) | Bearer API key for the Chutes OpenAI-compatible gateway. | -| `clarifai` | `clarifai` | Clarifai | API key, enterprise | [link](https://docs.clarifai.com) | Use your Clarifai PAT or app-specific API key. OmniRoute targets the OpenAI-compatible endpoint at https://api.clarifai.com/v2/ext/openai/v1 and authenticates with Authorization: Key . | -| `cloudflare-ai` | `cf` | Cloudflare Workers AI | API key | [link](https://developers.cloudflare.com/workers-ai) | Requires API Token AND Account ID (found at dash.cloudflare.com) | -| `codestral` | `codestral` | Codestral | API key | [link](https://mistral.ai) | — | -| `cohere` | `cohere` | Cohere | API key | [link](https://cohere.com) | Free Trial: 1,000 API calls/month for testing, no credit card required | -| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint. | -| `coze` | `coze` | Coze | API key | [link](https://coze.com) | Get API key at coze.com/open/api | -| `crof` | `crof` | CrofAI | API key | [link](https://crof.ai) | — | -| `databricks` | `databricks` | Databricks | API key, enterprise | [link](https://www.databricks.com) | — | -| `datarobot` | `datarobot` | DataRobot | API key, enterprise | [link](https://docs.datarobot.com) | Use your DataRobot API token. Optional Base URL can be the account root (for LLM Gateway) or a deployment URL under /api/v2/deployments/. | -| `deepinfra` | `deepinfra` | DeepInfra | API key | [link](https://deepinfra.com) | Free signup credits for API testing and model exploration | -| `deepseek` | `ds` | DeepSeek | API key | [link](https://platform.deepseek.com) | 5M free tokens on signup - no credit card required | -| `dify` | `dify` | Dify | API key | [link](https://dify.ai) | Get API key from your Dify instance. | -| `dit` | `dai` | DIT.ai | API key | [link](https://dit.ai) | Use your dit.ai API key in Authorization: Bearer . Fully OpenAI-compatible — a drop-in replacement, just change the base URL to https://api.dit.ai/v1. | -| `doubao` | `doubao` | Doubao | API key | [link](https://doubao.com) | Get API key at console.volcengine.com | -| `empower` | `empower` | Empower | API key, aggregator | [link](https://docs.empower.dev) | Bearer API key for the Empower OpenAI-compatible endpoint. | -| `fal-ai` | `fal` | Fal.ai | API key, image | [link](https://fal.ai) | — | -| `featherless-ai` | `featherless` | Featherless AI | API key | [link](https://featherless.ai) | Free tier available — no credit card required | -| `fenayai` | `fenayai` | FenayAI | API key, aggregator | [link](https://fenayai.com) | Bearer API key for the FenayAI OpenAI-compatible gateway. | -| `firecrawl` | `fc` | Firecrawl | API key | [link](https://firecrawl.dev) | — | -| `fireworks` | `fireworks` | Fireworks AI | API key | [link](https://fireworks.ai) | $1 free starter credits on signup for API testing | -| `freeaiapikey` | `faik` | FreeAIAPIKey | API key | [link](https://freeaiapikey.com) | — | -| `freemodel-dev` | `fmd` | FreeModel.dev | API key | [link](https://freemodel.dev) | $300 free credits on signup — no credit card required. Access GPT-5.4 and GPT-5.5 (OpenAI's latest flagship models) through an OpenAI-compatible API. | -| `friendliai` | `friendli` | FriendliAI | API key | [link](https://friendli.ai) | Free tier for serverless inference — no credit card required | -| `galadriel` | `galadriel` | Galadriel | API key | [link](https://galadriel.com) | ⚠️ **DEPRECATED.** api.galadriel.ai no longer resolves (sweep 2026-06-19); the inference API appears discontinued. | -| `gemini` | `gemini` | Gemini (Google AI Studio) | API key | [link](https://aistudio.google.com) | Free forever: 1,500 req/day for Gemini 2.5 Flash — no credit card, get key at aistudio.google.com | -| `getgoapi` | `ggo` | GoAPI | API key, aggregator | [link](https://api.getgoapi.com) | — | -| `gigachat` | `gigachat` | GigaChat (Sber) | API key | [link](https://developers.sber.ru) | — | -| `github-models` | `ghm` | GitHub Models | API key | [link](https://github.com/marketplace/models) | Create a GitHub PAT with 'models: read' scope at github.com/settings/tokens | -| `gitlab` | `gitlab` | GitLab Duo PAT | API key | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | GitLab personal access token for the public Code Suggestions API. Configure a self-hosted base URL when not using gitlab.com. | -| `gitlawb` | `glb` | Gitlawb Opengateway (MiMo) | API key | [link](https://opengateway.gitlawb.com) | Free MiMo (xiaomi/mimo-v2.5) revoked 2026-05 — Opengateway is now a pay-as-you-go credit gateway; no recurring free model. | -| `gitlawb-gmi` | `glb-gmi` | Gitlawb Opengateway (GMI Cloud) | API key | [link](https://opengateway.gitlawb.com) | Free Nemotron promo ended 2026-06 — the GMI Cloud route is now pay-as-you-go credit only. | -| `glhf` | `glhf` | GLHF Chat | API key, aggregator | [link](https://glhf.chat) | ⚠️ **DEPRECATED.** glhf.chat shut down (2026); its api.laf.run gateway no longer serves the catalog (sweep 2026-06-19). | -| `glm` | `glm` | GLM Coding | API key | [link](https://z.ai/subscribe) | — | -| `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — | -| `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — | -| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card | -| `hackclub` | `hc` | Hackclub AI | API key, aggregator | [link](https://ai.hackclub.com) | Sign in with your Hack Club account at ai.hackclub.com. | -| `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api | -| `heroku` | `heroku` | Heroku AI | API key, enterprise | [link](https://www.heroku.com) | — | -| `huggingchat` | `huggingchat` | HuggingChat | API key | [link](https://huggingface.co/chat) | No API key required for basic access. | -| `huggingface` | `hf` | HuggingFace | API key | [link](https://huggingface.co) | Free Inference API for thousands of models (Whisper, VITS, SDXL…) | -| `hyperbolic` | `hyp` | Hyperbolic | API key | [link](https://hyperbolic.xyz) | $1-5 trial credits on signup for serverless inference | -| `ideogram` | `ideo` | Ideogram | API key | [link](https://ideogram.ai) | Get API key at ideogram.ai/docs/api | -| `iflytek` | `iflytek` | iFlytek Spark | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | -| `inclusionai` | `inclusion` | InclusionAI | API key | [link](https://inclusionai.com) | ⚠️ **DEPRECATED.** api.inclusionai.tech no longer resolves (sweep 2026-06-19); the inference API appears discontinued. | -| `inference-net` | `inet` | Inference.net | API key | [link](https://inference.net) | $25 free credits on signup plus research grants available | -| `jina-ai` | `jina` | Jina AI | API key, embed/rerank | [link](https://jina.ai) | Bearer API key for the Jina AI rerank API. | -| `jina-reader` | `jr` | Jina Reader | API key | [link](https://jina.ai/reader) | — | -| `kie` | `kie` | KIE.AI | API key | [link](https://kie.ai) | — | -| `kilo-gateway` | `kg` | Kilo Gateway | API key, aggregator | [link](https://kilo.ai) | — | -| `kimi` | `kimi` | Kimi | API key | [link](https://platform.moonshot.ai) | — | -| `kimi-coding-apikey` | `kmca` | Kimi Coding (API Key) | API key | [link](https://www.kimi.com/code) | — | -| `kluster` | `kluster` | Kluster AI | API key | [link](https://kluster.ai) | ⚠️ **DEPRECATED.** kluster.ai shut down (2026-06-09); api.kluster.ai no longer resolves (sweep 2026-06-19). Use another OpenAI-compatible provider. | -| `lambda-ai` | `lambda` | Lambda AI | API key | [link](https://lambda.ai) | — | -| `laozhang` | `lz` | LaoZhang AI | API key, aggregator | [link](https://api.laozhang.ai) | — | -| `leonardo` | `leo` | Leonardo AI | API key, video | [link](https://leonardo.ai) | Get API key at leonardo.ai/developer | -| `liquid` | `liquid` | Liquid AI | API key | [link](https://liquid.ai) | Get API key at liquid.ai | -| `llamagate` | `llamagate` | LlamaGate | API key | [link](https://llamagate.ai) | — | -| `llm7` | `llm7` | LLM7.io | API key | [link](https://llm7.io) | No signup required - 2 req/s, 20 RPM, 100 req/hr free tier | -| `longcat` | `lc` | LongCat AI | API key | [link](https://longcat.chat/platform/docs) | Free: 5M tokens/day on LongCat-2.0-Preview (Flash models retired 2026-05-29); up to 120M/day via feedback. | -| `maritalk` | `maritalk` | Maritalk | API key | [link](https://www.maritaca.ai) | — | -| `meta-llama` | `meta` | Meta Llama API | API key | [link](https://llama.developer.meta.com) | — | -| `minimax` | `minimax` | Minimax Coding | API key, video | [link](https://www.minimax.io) | — | -| `minimax-cn` | `minimax-cn` | Minimax (China) | API key | [link](https://www.minimaxi.com) | — | -| `mistral` | `mistral` | Mistral | API key | [link](https://mistral.ai) | Free Experiment tier: rate-limited access to all models, no credit card required | -| `modal` | `mdl` | Modal | API key, enterprise | [link](https://modal.com/docs) | Use the bearer token that protects your Modal deployment, if enabled. Base URL should point to your OpenAI-compatible Modal app, for example https://--.modal.run/v1. | -| `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | Get API key at monsterapi.ai | -| `moonshot` | `moonshot` | Moonshot AI | API key | [link](https://platform.moonshot.ai) | — | -| `morph` | `morph` | Morph | API key | [link](https://morphllm.com) | Free tier: 250K credits/month, $0 | -| `nanogpt` | `nanogpt` | NanoGPT | API key | [link](https://nano-gpt.com) | — | -| `nebius` | `nebius` | Nebius AI | API key | [link](https://nebius.com) | ~$1 trial credits on signup for API testing | -| `nlpcloud` | `nlpc` | NLP Cloud | API key | [link](https://docs.nlpcloud.com) | Use your NLP Cloud API key in Authorization: Token . OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu//chatbot by default. | -| `nomic` | `nomic` | Nomic | API key | [link](https://nomic.ai) | Get API key at atlas.nomic.ai | -| `nous-research` | `nous` | Nous Research | API key | [link](https://portal.nousresearch.com/help) | Use your Nous Portal API key. OmniRoute targets the official OpenAI-compatible inference endpoint at https://inference-api.nousresearch.com/v1. | -| `novita` | `novita` | Novita AI | API key, aggregator | [link](https://novita.ai) | $0.50 trial credits on signup (valid about 1 year) | -| `nscale` | `nscale` | nScale | API key | [link](https://nscale.com) | $5 free credits on signup for inference testing | -| `nvidia` | `nvidia` | NVIDIA NIM | API key | [link](https://build.nvidia.com) | Free dev access: ~40 RPM, 70+ models (Kimi K2.5, GLM 4.7, DeepSeek V3.2...) | -| `oci` | `oci` | OCI Generative AI | API key, enterprise | [link](https://www.oracle.com/artificial-intelligence/generative-ai) | Use your OCI Generative AI API key or IAM bearer token. Base URL can be https://inference.generativeai..oci.oraclecloud.com/openai/v1/. | -| `ollama-cloud` | `ollamacloud` | Ollama Cloud | API key | [link](https://ollama.com/settings/api-keys) | — | -| `openadapter` | `oad` | OpenAdapter | API key | [link](https://openadapter.dev) | Use your OpenAdapter API key in Authorization: Bearer sk-cv-. Fully OpenAI-compatible. API base URL: https://api.openadapter.in/v1. | -| `openai` | `openai` | OpenAI | API key | [link](https://platform.openai.com) | — | -| `opencode-go` | `opencode-go` | OpenCode Go | API key | [link](https://opencode.ai/go) | — | -| `opencode-zen` | `opencode-zen` | OpenCode Zen | API key | [link](https://opencode.ai/zen) | — | -| `openrouter` | `openrouter` | OpenRouter | API key, aggregator | [link](https://openrouter.ai) | Free models at $0/token with :free suffix - 20 RPM / 200 RPD | -| `orcarouter` | `orcarouter` | OrcaRouter | API key | [link](https://www.orcarouter.ai) | — | -| `ovhcloud` | `ovh` | OVHcloud AI | API key | [link](https://www.ovhcloud.com) | — | -| `perplexity` | `pplx` | Perplexity | API key | [link](https://www.perplexity.ai) | — | -| `phind` | `phind` | Phind | API key | [link](https://phind.com) | Get API key at phind.com | -| `piapi` | `pi` | PiAPI | API key, aggregator | [link](https://piapi.ai) | — | -| `poe` | `poe` | Poe | API key, aggregator | [link](https://creator.poe.com/api-reference) | Bearer API key for the Poe OpenAI-compatible API. | -| `pollinations` | `pol` | Pollinations AI | API key, video | [link](https://pollinations.ai) | Free keyless tier: openai, openai-fast, openai-large, qwen-coder, mistral, deepseek, grok, gemini-flash-lite-3.1, perplexity-fast, perplexity-reasoning. Premium models (claude, gemini, midijourney) require a Pollinations API key from enter.pollinations.ai. | -| `predibase` | `predibase` | Predibase | API key | [link](https://predibase.com) | ⚠️ **DEPRECATED.** serving.app.predibase.com no longer resolves (sweep 2026-06-19); the managed serving API appears discontinued. | -| `publicai` | `publicai` | PublicAI | API key | [link](https://publicai.co) | Requires an API key — one-time signup credit, then paid | -| `puter` | `pu` | Puter AI | API key | [link](https://puter.com) | Get token at puter.com/dashboard → Copy Auth Token | -| `qianfan` | `qianfan` | Baidu Qianfan | API key | [link](https://cloud.baidu.com/product/wenxinworkshop) | — | -| `recraft` | `recraft` | Recraft | API key, image | [link](https://recraft.ai) | — | -| `reka` | `reka` | Reka | API key | [link](https://docs.reka.ai/chat/overview) | Use your Reka API key. OmniRoute supports the OpenAI-compatible base URL https://api.reka.ai/v1 and sends both Authorization and X-Api-Key headers for compatibility. | -| `runwayml` | `runway` | Runway | API key, video | [link](https://docs.dev.runwayml.com) | Use your Runway API key in Authorization: Bearer . OmniRoute targets the current Runway API at https://api.dev.runwayml.com/v1 and sends the required X-Runway-Version header automatically. | -| `sambanova` | `samba` | SambaNova | API key | [link](https://sambanova.ai) | $5 free credits on signup (30-day validity), no credit card required | -| `sap` | `sap` | SAP Generative AI Hub | API key, enterprise | [link](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/generative-ai-hub-in-sap-ai-core) | Use your SAP AI Core bearer token. Base URL can be your AI_API_URL root or a deploymentUrl from Generative AI Hub. | -| `scaleway` | `scw` | Scaleway AI | API key | [link](https://www.scaleway.com/en/ai/generative-apis) | 1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B | -| `sensenova` | `sensenova` | SenseNova | API key | [link](https://platform.sensenova.cn) | Get API key at platform.sensenova.cn | -| `siliconflow` | `siliconflow` | SiliconFlow | API key | [link](https://cloud.siliconflow.com) | $1 free credits plus permanently free models after identity verification | -| `snowflake` | `snowflake` | Snowflake Cortex | API key, enterprise | [link](https://www.snowflake.com) | — | -| `sparkdesk` | `sparkdesk` | SparkDesk | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | -| `stability-ai` | `stability` | Stability AI | API key, image | [link](https://stability.ai) | — | -| `stepfun` | `stepfun` | StepFun | API key | [link](https://stepfun.com) | Get API key at platform.stepfun.com | -| `suno` | `suno` | Suno | API key | [link](https://suno.ai) | Paste session cookie from suno.ai (Clerk auth) | -| `synthetic` | `synthetic` | Synthetic | API key, aggregator | [link](https://synthetic.new) | — | -| `tencent` | `tencent` | Tencent Hunyuan | API key | [link](https://hunyuan.tencent.com) | Get API key at console.cloud.tencent.com | -| `thebai` | `thebai` | TheB.AI | API key, aggregator | [link](https://theb.ai) | Bearer API key for the TheB.AI OpenAI-compatible gateway. | -| `together` | `together` | Together AI | API key, video | [link](https://www.together.ai) | $25 signup credits + 3 permanently free models: Llama 3.3 70B, Vision, DeepSeek-R1 distill | -| `tokenrouter` | `trk` | TokenRouter | API key | [link](https://tokenrouter.com) | Use your TokenRouter API key in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.tokenrouter.com/v1. | -| `topaz` | `topaz` | Topaz | API key, image | [link](https://topazlabs.com) | — | -| `udio` | `udio` | Udio | API key | [link](https://udio.com) | Paste session cookie from udio.com (Supabase auth) | -| `uncloseai` | `unc` | UncloseAI | API key | [link](https://uncloseai.com) | No auth required. API accepts any non-empty string as key for identification. | -| `upstage` | `upstage` | Upstage | API key | [link](https://www.upstage.ai) | — | -| `v0-vercel` | `v0` | v0 (Vercel) | API key | [link](https://v0.dev) | — | -| `venice` | `venice` | Venice.ai | API key | [link](https://venice.ai) | — | -| `vercel-ai-gateway` | `vag` | Vercel AI Gateway | API key, aggregator | [link](https://vercel.com/docs/ai-gateway) | — | -| `vertex` | `vertex` | Vertex AI | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide Service Account JSON or OAuth access_token | -| `vertex-partner` | `vp` | Vertex AI Partners | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide the same Service Account JSON used for Vertex AI partner models. | -| `volcengine` | `volcengine` | Volcengine | API key | [link](https://www.volcengine.com) | — | -| `voyage-ai` | `voyage` | Voyage AI | API key, embed/rerank | [link](https://www.voyageai.com) | Bearer API key for Voyage AI embeddings and rerank APIs. | -| `wafer` | `wafer` | Wafer AI | API key | [link](https://wafer.ai) | — | -| `wandb` | `wandb` | Weights & Biases Inference | API key | [link](https://wandb.ai) | — | -| `watsonx` | `watsonx` | IBM watsonx.ai Gateway | API key, enterprise | [link](https://www.ibm.com/products/watsonx-ai) | Use your watsonx bearer token. Base URL can be https://.ml.cloud.ibm.com/ml/gateway/v1/ or a self-managed /ml/gateway/v1 endpoint. | -| `xai` | `xai` | xAI (Grok) | API key | [link](https://x.ai) | — | -| `xiaomi-mimo` | `mimo` | Xiaomi MiMo | API key | [link](https://mimo.mi.com) | — | -| `yi` | `yi` | Yi (01.AI) | API key | [link](https://01.ai) | Get API key at platform.lingyiwanwu.com | -| `zai` | `zai` | Z.AI | API key | [link](https://open.bigmodel.cn) | — | -| `zenmux` | `zm` | ZenMux | API key | [link](https://zenmux.ai) | Use your ZenMux API key in Authorization: Bearer . ZenMux is fully OpenAI-compatible. Base URL: https://zenmux.ai/api/v1. | +| ID | Alias | Name | Tags | Website | Notes | +| --------------------- | -------------- | ------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `360ai` | `360ai` | 360 AI | API key | [link](https://ai.360.cn) | Get API key at ai.360.cn | +| `agentrouter` | `agentrouter` | AgentRouter | API key, aggregator | [link](https://agentrouter.org) | $200 free credits on signup - multi-model routing gateway | +| `ai21` | `ai21` | AI21 Labs | API key | [link](https://www.ai21.com) | $10 trial credits on signup (valid 3 months), no credit card required | +| `aimlapi` | `aiml` | AI/ML API | API key, aggregator | [link](https://aimlapi.com) | Free tier paused (2026) — AI/ML API is now pay-as-you-go only (min $20 top-up); no recurring free credits. | +| `alibaba` | `ali` | Alibaba | API key | [link](https://dashscope-intl.aliyuncs.com) | — | +| `alibaba-cn` | `ali-cn` | Alibaba (China) | API key | [link](https://dashscope.aliyuncs.com) | — | +| `anthropic` | `anthropic` | Anthropic | API key | [link](https://platform.claude.com) | — | +| `api-airforce` | `af` | Api.airforce | API key | [link](https://api.airforce) | 55 free tier models including Grok-3, Claude 3.7, Qwen3, Kimi-K2, Gemini 2.5 Flash, DeepSeek-V3 | +| `arcee-ai` | `arcee` | Arcee AI | API key | [link](https://arcee.ai) | Get API key at arcee.ai | +| `azure-ai` | `azure-ai` | Azure AI Foundry | API key, enterprise | [link](https://learn.microsoft.com/azure/ai-foundry) | Use your Azure AI Foundry key. Base URL can be https://.services.ai.azure.com/openai/v1/ or https://.openai.azure.com/openai/v1/. | +| `azure-openai` | `azure` | Azure OpenAI | API key, enterprise | [link](https://azure.microsoft.com/products/ai-services/openai-service) | Use your Azure OpenAI API key. Base URL should be your resource endpoint, for example https://my-resource.openai.azure.com. | +| `baichuan` | `baichuan` | Baichuan | API key | [link](https://baichuan.com) | Get API key at platform.baichuan-ai.com | +| `baidu` | `baidu` | Baidu (ERNIE) | API key | [link](https://yiyan.baidu.com) | Get API key at console.bce.baidu.com | +| `bailian-coding-plan` | `bcp` | Alibaba Coding Plan | API key | [link](https://www.alibabacloud.com/help/en/model-studio/coding-plan) | — | +| `baseten` | `baseten` | Baseten | API key | [link](https://baseten.co) | $30 free trial credits for GPU inference | +| `bazaarlink` | `bzl` | BazaarLink | API key | [link](https://bazaarlink.ai) | Free tier with auto:free routing — zero-cost inference, no credit card required | +| `bedrock` | `bedrock` | Amazon Bedrock | API key, enterprise | [link](https://aws.amazon.com/bedrock) | Use your Amazon Bedrock API key and configure the AWS region where your models are enabled (for example eu-west-2). OmniRoute calls Bedrock's native Converse API directly. | +| `black-forest-labs` | `bfl` | Black Forest Labs | API key, image | [link](https://blackforestlabs.ai) | — | +| `blackbox` | `bb` | Blackbox AI | API key | [link](https://blackbox.ai) | Free tier: unlimited basic chat plus Minimax-M2.5, no credit card required | +| `bluesminds` | `bm` | BluesMinds | API key | [link](https://www.bluesminds.com) | Free daily pi credits — supports 200+ models including GPT-4o, GPT-4.1, Claude Sonnet 4.5, Gemini 2.0 Flash, DeepSeek V4, Qwen, Kimi K2 | +| `byteplus` | `bpm` | BytePlus ModelArk | API key | [link](https://console.byteplus.com/ark) | — | +| `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks | +| `cablyai` | `cablyai` | CablyAI | API key, aggregator | [link](https://cablyai.com) | Bearer API key for the CablyAI OpenAI-compatible gateway. | +| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card. | +| `chutes` | `chutes` | Chutes.ai | API key, aggregator | [link](https://chutes.ai) | Bearer API key for the Chutes OpenAI-compatible gateway. | +| `clarifai` | `clarifai` | Clarifai | API key, enterprise | [link](https://docs.clarifai.com) | Use your Clarifai PAT or app-specific API key. OmniRoute targets the OpenAI-compatible endpoint at https://api.clarifai.com/v2/ext/openai/v1 and authenticates with Authorization: Key . | +| `cloudflare-ai` | `cf` | Cloudflare Workers AI | API key | [link](https://developers.cloudflare.com/workers-ai) | Requires API Token AND Account ID (found at dash.cloudflare.com) | +| `codestral` | `codestral` | Codestral | API key | [link](https://mistral.ai) | — | +| `cohere` | `cohere` | Cohere | API key | [link](https://cohere.com) | Free Trial: 1,000 API calls/month for testing, no credit card required | +| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint. | +| `coze` | `coze` | Coze | API key | [link](https://coze.com) | Get API key at coze.com/open/api | +| `crof` | `crof` | CrofAI | API key | [link](https://crof.ai) | — | +| `databricks` | `databricks` | Databricks | API key, enterprise | [link](https://www.databricks.com) | — | +| `datarobot` | `datarobot` | DataRobot | API key, enterprise | [link](https://docs.datarobot.com) | Use your DataRobot API token. Optional Base URL can be the account root (for LLM Gateway) or a deployment URL under /api/v2/deployments/. | +| `deepinfra` | `deepinfra` | DeepInfra | API key | [link](https://deepinfra.com) | Free signup credits for API testing and model exploration | +| `deepseek` | `ds` | DeepSeek | API key | [link](https://platform.deepseek.com) | 5M free tokens on signup - no credit card required | +| `dify` | `dify` | Dify | API key | [link](https://dify.ai) | Get API key from your Dify instance. | +| `dit` | `dai` | DIT.ai | API key | [link](https://dit.ai) | Use your dit.ai API key in Authorization: Bearer . Fully OpenAI-compatible — a drop-in replacement, just change the base URL to https://api.dit.ai/v1. | +| `doubao` | `doubao` | Doubao | API key | [link](https://doubao.com) | Get API key at console.volcengine.com | +| `empower` | `empower` | Empower | API key, aggregator | [link](https://docs.empower.dev) | Bearer API key for the Empower OpenAI-compatible endpoint. | +| `fal-ai` | `fal` | Fal.ai | API key, image | [link](https://fal.ai) | — | +| `factory` | `factory` | Factory | API key, aggregator | [link](https://factory.ai) | Bearer API key for the Factory (Factory Droids) OpenAI-compatible gateway. Same backend the `droid` CLI uses; subscription tier via `app.factory.ai`. OAuth follow-up tracked in issue #5060. | +| `featherless-ai` | `featherless` | Featherless AI | API key | [link](https://featherless.ai) | Free tier available — no credit card required | +| `fenayai` | `fenayai` | FenayAI | API key, aggregator | [link](https://fenayai.com) | Bearer API key for the FenayAI OpenAI-compatible gateway. | +| `firecrawl` | `fc` | Firecrawl | API key | [link](https://firecrawl.dev) | — | +| `fireworks` | `fireworks` | Fireworks AI | API key | [link](https://fireworks.ai) | $1 free starter credits on signup for API testing | +| `freeaiapikey` | `faik` | FreeAIAPIKey | API key | [link](https://freeaiapikey.com) | — | +| `freemodel-dev` | `fmd` | FreeModel.dev | API key | [link](https://freemodel.dev) | $300 free credits on signup — no credit card required. Access GPT-5.4 and GPT-5.5 (OpenAI's latest flagship models) through an OpenAI-compatible API. | +| `friendliai` | `friendli` | FriendliAI | API key | [link](https://friendli.ai) | Free tier for serverless inference — no credit card required | +| `galadriel` | `galadriel` | Galadriel | API key | [link](https://galadriel.com) | ⚠️ **DEPRECATED.** api.galadriel.ai no longer resolves (sweep 2026-06-19); the inference API appears discontinued. | +| `gemini` | `gemini` | Gemini (Google AI Studio) | API key | [link](https://aistudio.google.com) | Free forever: 1,500 req/day for Gemini 2.5 Flash — no credit card, get key at aistudio.google.com | +| `getgoapi` | `ggo` | GoAPI | API key, aggregator | [link](https://api.getgoapi.com) | — | +| `gigachat` | `gigachat` | GigaChat (Sber) | API key | [link](https://developers.sber.ru) | — | +| `github-models` | `ghm` | GitHub Models | API key | [link](https://github.com/marketplace/models) | Create a GitHub PAT with 'models: read' scope at github.com/settings/tokens | +| `gitlab` | `gitlab` | GitLab Duo PAT | API key | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | GitLab personal access token for the public Code Suggestions API. Configure a self-hosted base URL when not using gitlab.com. | +| `gitlawb` | `glb` | Gitlawb Opengateway (MiMo) | API key | [link](https://opengateway.gitlawb.com) | Free MiMo (xiaomi/mimo-v2.5) revoked 2026-05 — Opengateway is now a pay-as-you-go credit gateway; no recurring free model. | +| `gitlawb-gmi` | `glb-gmi` | Gitlawb Opengateway (GMI Cloud) | API key | [link](https://opengateway.gitlawb.com) | Free Nemotron promo ended 2026-06 — the GMI Cloud route is now pay-as-you-go credit only. | +| `glhf` | `glhf` | GLHF Chat | API key, aggregator | [link](https://glhf.chat) | ⚠️ **DEPRECATED.** glhf.chat shut down (2026); its api.laf.run gateway no longer serves the catalog (sweep 2026-06-19). | +| `glm` | `glm` | GLM Coding | API key | [link](https://z.ai/subscribe) | — | +| `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — | +| `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — | +| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card | +| `hackclub` | `hc` | Hackclub AI | API key, aggregator | [link](https://ai.hackclub.com) | Sign in with your Hack Club account at ai.hackclub.com. | +| `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api | +| `heroku` | `heroku` | Heroku AI | API key, enterprise | [link](https://www.heroku.com) | — | +| `huggingchat` | `huggingchat` | HuggingChat | API key | [link](https://huggingface.co/chat) | No API key required for basic access. | +| `huggingface` | `hf` | HuggingFace | API key | [link](https://huggingface.co) | Free Inference API for thousands of models (Whisper, VITS, SDXL…) | +| `hyperbolic` | `hyp` | Hyperbolic | API key | [link](https://hyperbolic.xyz) | $1-5 trial credits on signup for serverless inference | +| `ideogram` | `ideo` | Ideogram | API key | [link](https://ideogram.ai) | Get API key at ideogram.ai/docs/api | +| `iflytek` | `iflytek` | iFlytek Spark | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | +| `inclusionai` | `inclusion` | InclusionAI | API key | [link](https://inclusionai.com) | ⚠️ **DEPRECATED.** api.inclusionai.tech no longer resolves (sweep 2026-06-19); the inference API appears discontinued. | +| `inference-net` | `inet` | Inference.net | API key | [link](https://inference.net) | $25 free credits on signup plus research grants available | +| `jina-ai` | `jina` | Jina AI | API key, embed/rerank | [link](https://jina.ai) | Bearer API key for the Jina AI rerank API. | +| `jina-reader` | `jr` | Jina Reader | API key | [link](https://jina.ai/reader) | — | +| `kie` | `kie` | KIE.AI | API key | [link](https://kie.ai) | — | +| `kilo-gateway` | `kg` | Kilo Gateway | API key, aggregator | [link](https://kilo.ai) | — | +| `kimi` | `kimi` | Kimi | API key | [link](https://platform.moonshot.ai) | — | +| `kimi-coding-apikey` | `kmca` | Kimi Coding (API Key) | API key | [link](https://www.kimi.com/code) | — | +| `kluster` | `kluster` | Kluster AI | API key | [link](https://kluster.ai) | ⚠️ **DEPRECATED.** kluster.ai shut down (2026-06-09); api.kluster.ai no longer resolves (sweep 2026-06-19). Use another OpenAI-compatible provider. | +| `lambda-ai` | `lambda` | Lambda AI | API key | [link](https://lambda.ai) | — | +| `laozhang` | `lz` | LaoZhang AI | API key, aggregator | [link](https://api.laozhang.ai) | — | +| `leonardo` | `leo` | Leonardo AI | API key, video | [link](https://leonardo.ai) | Get API key at leonardo.ai/developer | +| `liquid` | `liquid` | Liquid AI | API key | [link](https://liquid.ai) | Get API key at liquid.ai | +| `llamagate` | `llamagate` | LlamaGate | API key | [link](https://llamagate.ai) | — | +| `llm7` | `llm7` | LLM7.io | API key | [link](https://llm7.io) | No signup required - 2 req/s, 20 RPM, 100 req/hr free tier | +| `longcat` | `lc` | LongCat AI | API key | [link](https://longcat.chat/platform/docs) | Free: 5M tokens/day on LongCat-2.0-Preview (Flash models retired 2026-05-29); up to 120M/day via feedback. | +| `maritalk` | `maritalk` | Maritalk | API key | [link](https://www.maritaca.ai) | — | +| `meta-llama` | `meta` | Meta Llama API | API key | [link](https://llama.developer.meta.com) | — | +| `minimax` | `minimax` | Minimax Coding | API key, video | [link](https://www.minimax.io) | — | +| `minimax-cn` | `minimax-cn` | Minimax (China) | API key | [link](https://www.minimaxi.com) | — | +| `mistral` | `mistral` | Mistral | API key | [link](https://mistral.ai) | Free Experiment tier: rate-limited access to all models, no credit card required | +| `modal` | `mdl` | Modal | API key, enterprise | [link](https://modal.com/docs) | Use the bearer token that protects your Modal deployment, if enabled. Base URL should point to your OpenAI-compatible Modal app, for example https://--.modal.run/v1. | +| `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | Get API key at monsterapi.ai | +| `moonshot` | `moonshot` | Moonshot AI | API key | [link](https://platform.moonshot.ai) | — | +| `morph` | `morph` | Morph | API key | [link](https://morphllm.com) | Free tier: 250K credits/month, $0 | +| `nanogpt` | `nanogpt` | NanoGPT | API key | [link](https://nano-gpt.com) | — | +| `nebius` | `nebius` | Nebius AI | API key | [link](https://nebius.com) | ~$1 trial credits on signup for API testing | +| `nlpcloud` | `nlpc` | NLP Cloud | API key | [link](https://docs.nlpcloud.com) | Use your NLP Cloud API key in Authorization: Token . OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu//chatbot by default. | +| `nomic` | `nomic` | Nomic | API key | [link](https://nomic.ai) | Get API key at atlas.nomic.ai | +| `nous-research` | `nous` | Nous Research | API key | [link](https://portal.nousresearch.com/help) | Use your Nous Portal API key. OmniRoute targets the official OpenAI-compatible inference endpoint at https://inference-api.nousresearch.com/v1. | +| `novita` | `novita` | Novita AI | API key, aggregator | [link](https://novita.ai) | $0.50 trial credits on signup (valid about 1 year) | +| `nscale` | `nscale` | nScale | API key | [link](https://nscale.com) | $5 free credits on signup for inference testing | +| `nvidia` | `nvidia` | NVIDIA NIM | API key | [link](https://build.nvidia.com) | Free dev access: ~40 RPM, 70+ models (Kimi K2.5, GLM 4.7, DeepSeek V3.2...) | +| `oci` | `oci` | OCI Generative AI | API key, enterprise | [link](https://www.oracle.com/artificial-intelligence/generative-ai) | Use your OCI Generative AI API key or IAM bearer token. Base URL can be https://inference.generativeai..oci.oraclecloud.com/openai/v1/. | +| `ollama-cloud` | `ollamacloud` | Ollama Cloud | API key | [link](https://ollama.com/settings/api-keys) | — | +| `openadapter` | `oad` | OpenAdapter | API key | [link](https://openadapter.dev) | Use your OpenAdapter API key in Authorization: Bearer sk-cv-. Fully OpenAI-compatible. API base URL: https://api.openadapter.in/v1. | +| `openai` | `openai` | OpenAI | API key | [link](https://platform.openai.com) | — | +| `opencode-go` | `opencode-go` | OpenCode Go | API key | [link](https://opencode.ai/go) | — | +| `opencode-zen` | `opencode-zen` | OpenCode Zen | API key | [link](https://opencode.ai/zen) | — | +| `openrouter` | `openrouter` | OpenRouter | API key, aggregator | [link](https://openrouter.ai) | Free models at $0/token with :free suffix - 20 RPM / 200 RPD | +| `orcarouter` | `orcarouter` | OrcaRouter | API key | [link](https://www.orcarouter.ai) | — | +| `ovhcloud` | `ovh` | OVHcloud AI | API key | [link](https://www.ovhcloud.com) | — | +| `perplexity` | `pplx` | Perplexity | API key | [link](https://www.perplexity.ai) | — | +| `phind` | `phind` | Phind | API key | [link](https://phind.com) | Get API key at phind.com | +| `piapi` | `pi` | PiAPI | API key, aggregator | [link](https://piapi.ai) | — | +| `poe` | `poe` | Poe | API key, aggregator | [link](https://creator.poe.com/api-reference) | Bearer API key for the Poe OpenAI-compatible API. | +| `pollinations` | `pol` | Pollinations AI | API key, video | [link](https://pollinations.ai) | Free keyless tier: openai, openai-fast, openai-large, qwen-coder, mistral, deepseek, grok, gemini-flash-lite-3.1, perplexity-fast, perplexity-reasoning. Premium models (claude, gemini, midijourney) require a Pollinations API key from enter.pollinations.ai. | +| `predibase` | `predibase` | Predibase | API key | [link](https://predibase.com) | ⚠️ **DEPRECATED.** serving.app.predibase.com no longer resolves (sweep 2026-06-19); the managed serving API appears discontinued. | +| `publicai` | `publicai` | PublicAI | API key | [link](https://publicai.co) | Requires an API key — one-time signup credit, then paid | +| `puter` | `pu` | Puter AI | API key | [link](https://puter.com) | Get token at puter.com/dashboard → Copy Auth Token | +| `qianfan` | `qianfan` | Baidu Qianfan | API key | [link](https://cloud.baidu.com/product/wenxinworkshop) | — | +| `recraft` | `recraft` | Recraft | API key, image | [link](https://recraft.ai) | — | +| `reka` | `reka` | Reka | API key | [link](https://docs.reka.ai/chat/overview) | Use your Reka API key. OmniRoute supports the OpenAI-compatible base URL https://api.reka.ai/v1 and sends both Authorization and X-Api-Key headers for compatibility. | +| `runwayml` | `runway` | Runway | API key, video | [link](https://docs.dev.runwayml.com) | Use your Runway API key in Authorization: Bearer . OmniRoute targets the current Runway API at https://api.dev.runwayml.com/v1 and sends the required X-Runway-Version header automatically. | +| `sambanova` | `samba` | SambaNova | API key | [link](https://sambanova.ai) | $5 free credits on signup (30-day validity), no credit card required | +| `sap` | `sap` | SAP Generative AI Hub | API key, enterprise | [link](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/generative-ai-hub-in-sap-ai-core) | Use your SAP AI Core bearer token. Base URL can be your AI_API_URL root or a deploymentUrl from Generative AI Hub. | +| `scaleway` | `scw` | Scaleway AI | API key | [link](https://www.scaleway.com/en/ai/generative-apis) | 1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B | +| `sensenova` | `sensenova` | SenseNova | API key | [link](https://platform.sensenova.cn) | Get API key at platform.sensenova.cn | +| `siliconflow` | `siliconflow` | SiliconFlow | API key | [link](https://cloud.siliconflow.com) | $1 free credits plus permanently free models after identity verification | +| `snowflake` | `snowflake` | Snowflake Cortex | API key, enterprise | [link](https://www.snowflake.com) | — | +| `sparkdesk` | `sparkdesk` | SparkDesk | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | +| `stability-ai` | `stability` | Stability AI | API key, image | [link](https://stability.ai) | — | +| `stepfun` | `stepfun` | StepFun | API key | [link](https://stepfun.com) | Get API key at platform.stepfun.com | +| `suno` | `suno` | Suno | API key | [link](https://suno.ai) | Paste session cookie from suno.ai (Clerk auth) | +| `synthetic` | `synthetic` | Synthetic | API key, aggregator | [link](https://synthetic.new) | — | +| `tencent` | `tencent` | Tencent Hunyuan | API key | [link](https://hunyuan.tencent.com) | Get API key at console.cloud.tencent.com | +| `thebai` | `thebai` | TheB.AI | API key, aggregator | [link](https://theb.ai) | Bearer API key for the TheB.AI OpenAI-compatible gateway. | +| `together` | `together` | Together AI | API key, video | [link](https://www.together.ai) | $25 signup credits + 3 permanently free models: Llama 3.3 70B, Vision, DeepSeek-R1 distill | +| `tokenrouter` | `trk` | TokenRouter | API key | [link](https://tokenrouter.com) | Use your TokenRouter API key in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.tokenrouter.com/v1. | +| `topaz` | `topaz` | Topaz | API key, image | [link](https://topazlabs.com) | — | +| `udio` | `udio` | Udio | API key | [link](https://udio.com) | Paste session cookie from udio.com (Supabase auth) | +| `uncloseai` | `unc` | UncloseAI | API key | [link](https://uncloseai.com) | No auth required. API accepts any non-empty string as key for identification. | +| `upstage` | `upstage` | Upstage | API key | [link](https://www.upstage.ai) | — | +| `v0-vercel` | `v0` | v0 (Vercel) | API key | [link](https://v0.dev) | — | +| `venice` | `venice` | Venice.ai | API key | [link](https://venice.ai) | — | +| `vercel-ai-gateway` | `vag` | Vercel AI Gateway | API key, aggregator | [link](https://vercel.com/docs/ai-gateway) | — | +| `vertex` | `vertex` | Vertex AI | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide Service Account JSON or OAuth access_token | +| `vertex-partner` | `vp` | Vertex AI Partners | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide the same Service Account JSON used for Vertex AI partner models. | +| `volcengine` | `volcengine` | Volcengine | API key | [link](https://www.volcengine.com) | — | +| `voyage-ai` | `voyage` | Voyage AI | API key, embed/rerank | [link](https://www.voyageai.com) | Bearer API key for Voyage AI embeddings and rerank APIs. | +| `wafer` | `wafer` | Wafer AI | API key | [link](https://wafer.ai) | — | +| `wandb` | `wandb` | Weights & Biases Inference | API key | [link](https://wandb.ai) | — | +| `watsonx` | `watsonx` | IBM watsonx.ai Gateway | API key, enterprise | [link](https://www.ibm.com/products/watsonx-ai) | Use your watsonx bearer token. Base URL can be https://.ml.cloud.ibm.com/ml/gateway/v1/ or a self-managed /ml/gateway/v1 endpoint. | +| `xai` | `xai` | xAI (Grok) | API key | [link](https://x.ai) | — | +| `xiaomi-mimo` | `mimo` | Xiaomi MiMo | API key | [link](https://mimo.mi.com) | — | +| `yi` | `yi` | Yi (01.AI) | API key | [link](https://01.ai) | Get API key at platform.lingyiwanwu.com | +| `zai` | `zai` | Z.AI | API key | [link](https://open.bigmodel.cn) | — | +| `zenmux` | `zm` | ZenMux | API key | [link](https://zenmux.ai) | Use your ZenMux API key in Authorization: Bearer . ZenMux is fully OpenAI-compatible. Base URL: https://zenmux.ai/api/v1. | ## Local Providers (11) -| ID | Alias | Name | Tags | Website | Notes | -|----|-------|------|------|---------|-------| -| `comfyui` | `comfyui` | ComfyUI | Local | [link](https://github.com/comfyanonymous/ComfyUI) | No API key required. Configure the local ComfyUI base URL (default: http://localhost:8188). | -| `docker-model-runner` | `dmr` | Docker Model Runner | Local, self-hosted | [link](https://docs.docker.com/ai/model-runner/) | API key optional. Configure the local Docker Model Runner OpenAI-compatible base URL (default: http://localhost:12434/v1). | -| `lemonade` | `lemonade` | Lemonade Server | Local, self-hosted | [link](https://lemonade-server.ai) | API key optional. Configure the local Lemonade OpenAI-compatible base URL (default: http://localhost:13305/api/v1). | -| `llama-cpp` | `llamacpp` | llama.cpp | Local, self-hosted | [link](https://github.com/ggml-org/llama.cpp) | API key optional (use any value, e.g. sk-no-key-required). Configure the llama-server OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). Note: if Llamafile is also installed, both default to port 8080 — run only one at a time or override the port. | -| `llamafile` | `llamafile` | Llamafile | Local, self-hosted | [link](https://github.com/Mozilla-Ocho/llamafile) | API key optional. Configure the local Llamafile OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). | -| `lm-studio` | `lmstudio` | LM Studio | Local, self-hosted | [link](https://lmstudio.ai) | API key optional. Configure the local LM Studio OpenAI-compatible base URL (default: http://localhost:1234/v1). | -| `oobabooga` | `ooba` | oobabooga | Local, self-hosted | [link](https://github.com/oobabooga/text-generation-webui) | API key optional. Configure the local oobabooga OpenAI-compatible base URL (default: http://localhost:5000/v1). | -| `sdwebui` | `sdwebui` | SD WebUI | Local | [link](https://github.com/AUTOMATIC1111/stable-diffusion-webui) | No API key required. Configure the local WebUI base URL (default: http://localhost:7860). | -| `triton` | `triton` | NVIDIA Triton | Local, self-hosted | [link](https://developer.nvidia.com/triton-inference-server) | API key optional. Configure the Triton OpenAI-compatible base URL (default: http://localhost:8000/v1). | -| `vllm` | `vllm` | vLLM | Local, self-hosted | [link](https://github.com/vllm-project/vllm) | API key optional. Configure the local vLLM OpenAI-compatible base URL (default: http://localhost:8000/v1). | -| `xinference` | `xinference` | XInference | Local, self-hosted | [link](https://inference.readthedocs.io) | API key optional. Configure the local XInference OpenAI-compatible base URL (default: http://localhost:9997/v1). | +| ID | Alias | Name | Tags | Website | Notes | +| --------------------- | ------------ | ------------------- | ------------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `comfyui` | `comfyui` | ComfyUI | Local | [link](https://github.com/comfyanonymous/ComfyUI) | No API key required. Configure the local ComfyUI base URL (default: http://localhost:8188). | +| `docker-model-runner` | `dmr` | Docker Model Runner | Local, self-hosted | [link](https://docs.docker.com/ai/model-runner/) | API key optional. Configure the local Docker Model Runner OpenAI-compatible base URL (default: http://localhost:12434/v1). | +| `lemonade` | `lemonade` | Lemonade Server | Local, self-hosted | [link](https://lemonade-server.ai) | API key optional. Configure the local Lemonade OpenAI-compatible base URL (default: http://localhost:13305/api/v1). | +| `llama-cpp` | `llamacpp` | llama.cpp | Local, self-hosted | [link](https://github.com/ggml-org/llama.cpp) | API key optional (use any value, e.g. sk-no-key-required). Configure the llama-server OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). Note: if Llamafile is also installed, both default to port 8080 — run only one at a time or override the port. | +| `llamafile` | `llamafile` | Llamafile | Local, self-hosted | [link](https://github.com/Mozilla-Ocho/llamafile) | API key optional. Configure the local Llamafile OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). | +| `lm-studio` | `lmstudio` | LM Studio | Local, self-hosted | [link](https://lmstudio.ai) | API key optional. Configure the local LM Studio OpenAI-compatible base URL (default: http://localhost:1234/v1). | +| `oobabooga` | `ooba` | oobabooga | Local, self-hosted | [link](https://github.com/oobabooga/text-generation-webui) | API key optional. Configure the local oobabooga OpenAI-compatible base URL (default: http://localhost:5000/v1). | +| `sdwebui` | `sdwebui` | SD WebUI | Local | [link](https://github.com/AUTOMATIC1111/stable-diffusion-webui) | No API key required. Configure the local WebUI base URL (default: http://localhost:7860). | +| `triton` | `triton` | NVIDIA Triton | Local, self-hosted | [link](https://developer.nvidia.com/triton-inference-server) | API key optional. Configure the Triton OpenAI-compatible base URL (default: http://localhost:8000/v1). | +| `vllm` | `vllm` | vLLM | Local, self-hosted | [link](https://github.com/vllm-project/vllm) | API key optional. Configure the local vLLM OpenAI-compatible base URL (default: http://localhost:8000/v1). | +| `xinference` | `xinference` | XInference | Local, self-hosted | [link](https://inference.readthedocs.io) | API key optional. Configure the local XInference OpenAI-compatible base URL (default: http://localhost:9997/v1). | ## Search Providers (11) -| ID | Alias | Name | Tags | Website | Notes | -|----|-------|------|------|---------|-------| -| `brave-search` | `brave-search` | Brave Search | Search | [link](https://brave.com/search/api) | Subscription token from Brave Search API dashboard | -| `exa-search` | `exa-search` | Exa Search | Search | [link](https://exa.ai) | API key from dashboard.exa.ai | -| `google-pse-search` | `google-pse` | Google Programmable Search | Search | [link](https://developers.google.com/custom-search/v1/overview) | Requires a Google API key and your Programmable Search Engine ID (cx) | -| `linkup-search` | `linkup` | Linkup Search | Search | [link](https://docs.linkup.so) | Bearer API key from the Linkup dashboard | -| `ollama-search` | `ollama-search` | Ollama Search | Search | [link](https://ollama.com/settings/api-keys) | Same API key as Ollama Cloud (from ollama.com/settings/api-keys) | -| `perplexity-search` | `pplx-search` | Perplexity Search | Search | [link](https://docs.perplexity.ai/guides/search-quickstart) | Same API key as Perplexity (pplx-...) | -| `searchapi-search` | `searchapi` | SearchAPI | Search | [link](https://www.searchapi.io/docs) | API key from SearchAPI (query param or Bearer auth) | -| `searxng-search` | `searxng` | SearXNG Search | Search | [link](https://docs.searxng.org) | API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access. | -| `serper-search` | `serper-search` | Serper Search | Search | [link](https://serper.dev) | API key from serper.dev dashboard | -| `tavily-search` | `tavily-search` | Tavily Search | Search | [link](https://tavily.com) | API key from app.tavily.com (format: tvly-...) | -| `youcom-search` | `youcom-search` | You.com Search | Search | [link](https://you.com/docs/search/overview) | X-API-Key from the You.com platform dashboard | +| ID | Alias | Name | Tags | Website | Notes | +| ------------------- | --------------- | -------------------------- | ------ | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `brave-search` | `brave-search` | Brave Search | Search | [link](https://brave.com/search/api) | Subscription token from Brave Search API dashboard | +| `exa-search` | `exa-search` | Exa Search | Search | [link](https://exa.ai) | API key from dashboard.exa.ai | +| `google-pse-search` | `google-pse` | Google Programmable Search | Search | [link](https://developers.google.com/custom-search/v1/overview) | Requires a Google API key and your Programmable Search Engine ID (cx) | +| `linkup-search` | `linkup` | Linkup Search | Search | [link](https://docs.linkup.so) | Bearer API key from the Linkup dashboard | +| `ollama-search` | `ollama-search` | Ollama Search | Search | [link](https://ollama.com/settings/api-keys) | Same API key as Ollama Cloud (from ollama.com/settings/api-keys) | +| `perplexity-search` | `pplx-search` | Perplexity Search | Search | [link](https://docs.perplexity.ai/guides/search-quickstart) | Same API key as Perplexity (pplx-...) | +| `searchapi-search` | `searchapi` | SearchAPI | Search | [link](https://www.searchapi.io/docs) | API key from SearchAPI (query param or Bearer auth) | +| `searxng-search` | `searxng` | SearXNG Search | Search | [link](https://docs.searxng.org) | API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access. | +| `serper-search` | `serper-search` | Serper Search | Search | [link](https://serper.dev) | API key from serper.dev dashboard | +| `tavily-search` | `tavily-search` | Tavily Search | Search | [link](https://tavily.com) | API key from app.tavily.com (format: tvly-...) | +| `youcom-search` | `youcom-search` | You.com Search | Search | [link](https://you.com/docs/search/overview) | X-API-Key from the You.com platform dashboard | ## Audio-only Providers (7) -| ID | Alias | Name | Tags | Website | Notes | -|----|-------|------|------|---------|-------| -| `assemblyai` | `aai` | AssemblyAI | Audio | [link](https://assemblyai.com) | — | -| `aws-polly` | `polly` | AWS Polly | Audio | [link](https://aws.amazon.com/polly/) | Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region. | -| `cartesia` | `cartesia` | Cartesia | Audio | [link](https://cartesia.ai) | — | -| `deepgram` | `dg` | Deepgram | Audio | [link](https://deepgram.com) | — | -| `elevenlabs` | `el` | ElevenLabs | Audio | [link](https://elevenlabs.io) | — | -| `inworld` | `inworld` | Inworld | Audio | [link](https://inworld.ai) | — | -| `playht` | `playht` | PlayHT | Audio | [link](https://play.ht) | — | +| ID | Alias | Name | Tags | Website | Notes | +| ------------ | ---------- | ---------- | ----- | ------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `assemblyai` | `aai` | AssemblyAI | Audio | [link](https://assemblyai.com) | — | +| `aws-polly` | `polly` | AWS Polly | Audio | [link](https://aws.amazon.com/polly/) | Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region. | +| `cartesia` | `cartesia` | Cartesia | Audio | [link](https://cartesia.ai) | — | +| `deepgram` | `dg` | Deepgram | Audio | [link](https://deepgram.com) | — | +| `elevenlabs` | `el` | ElevenLabs | Audio | [link](https://elevenlabs.io) | — | +| `inworld` | `inworld` | Inworld | Audio | [link](https://inworld.ai) | — | +| `playht` | `playht` | PlayHT | Audio | [link](https://play.ht) | — | ## Upstream Proxy Providers (2) -| ID | Alias | Name | Tags | Website | Notes | -|----|-------|------|------|---------|-------| -| `9router` | `nr` | 9router | Upstream proxy | [link](https://www.npmjs.com/package/9router) | — | -| `cliproxyapi` | `cpa` | CLIProxyAPI | Upstream proxy | [link](https://github.com/router-for-me/CLIProxyAPI) | — | +| ID | Alias | Name | Tags | Website | Notes | +| ------------- | ----- | ----------- | -------------- | ---------------------------------------------------- | ----- | +| `9router` | `nr` | 9router | Upstream proxy | [link](https://www.npmjs.com/package/9router) | — | +| `cliproxyapi` | `cpa` | CLIProxyAPI | Upstream proxy | [link](https://github.com/router-for-me/CLIProxyAPI) | — | ## Cloud Agent Providers (3) -| ID | Alias | Name | Tags | Website | Notes | -|----|-------|------|------|---------|-------| -| `codex-cloud` | `codex-cloud` | Codex Cloud | Cloud agent | [link](https://openai.com/codex) | OpenAI API key with Codex Cloud task access. | -| `devin` | `devin` | Devin | Cloud agent | [link](https://devin.ai) | Devin API key for cloud agent sessions. | -| `jules` | `jules` | Google Jules | Cloud agent | [link](https://jules.google) | Jules API key for creating and managing cloud coding tasks. | +| ID | Alias | Name | Tags | Website | Notes | +| ------------- | ------------- | ------------ | ----------- | -------------------------------- | ----------------------------------------------------------- | +| `codex-cloud` | `codex-cloud` | Codex Cloud | Cloud agent | [link](https://openai.com/codex) | OpenAI API key with Codex Cloud task access. | +| `devin` | `devin` | Devin | Cloud agent | [link](https://devin.ai) | Devin API key for cloud agent sessions. | +| `jules` | `jules` | Google Jules | Cloud agent | [link](https://jules.google) | Jules API key for creating and managing cloud coding tasks. | ## System Providers (1) -| ID | Alias | Name | Tags | Website | Notes | -|----|-------|------|------|---------|-------| -| `auto` | `auto` | Auto (Zero-Config) | System | — | — | +| ID | Alias | Name | Tags | Website | Notes | +| ------ | ------ | ------------------ | ------ | ------- | ----- | +| `auto` | `auto` | Auto (Zero-Config) | System | — | — | ## Sources of truth diff --git a/docs/security/STEALTH_GUIDE.md b/docs/security/STEALTH_GUIDE.md index 8250882c68..358152b30e 100644 --- a/docs/security/STEALTH_GUIDE.md +++ b/docs/security/STEALTH_GUIDE.md @@ -93,6 +93,7 @@ For third-party Anthropic relays that only accept "real Claude Code" traffic: - `CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0"` - `anthropic-beta = "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24"` by default - The per-connection "Enable redact-thinking beta" toggle adds `redact-thinking-2026-02-12` when a CC Compatible upstream specifically requires redacted thinking streams +- The per-connection "Enable summarized thinking display" toggle stores `providerSpecificData.requestDefaults.summarizeThinking` and adds `display: "summarized"` to CC Compatible thinking requests that did not already set a display mode - `CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07"` (Opus/Sonnet 4.x family) - Default path: `/v1/messages?beta=true` diff --git a/electron/package-lock.json b/electron/package-lock.json index 14cb8260cc..08372d4a65 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.37", + "version": "3.8.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.37", + "version": "3.8.38", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/electron/package.json b/electron/package.json index ec964eea18..db7f7746d5 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.37", + "version": "3.8.38", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index 3ddcadc550..37a7c5ce6e 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -64,6 +64,57 @@ export function mergeClientAnthropicBeta( return baseList.join(","); } +/** + * Collapse a list of comma-list header values into a deduped, trimmed token + * array. Empty/undefined/null entries are dropped. Used to reconcile the + * case-variant `anthropic-version` / `anthropic-beta` headers below. + */ +function uniqueCommaValues(values: Array): string[] { + return [ + ...new Set( + values + .filter((value) => value !== undefined && value !== null && value !== "") + .flatMap((value) => String(value).split(",")) + .map((value) => value.trim()) + .filter(Boolean) + ), + ]; +} + +/** + * Dedupe case-variant Anthropic headers in-place. Node/undici's fetch merges + * `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value, + * which the Anthropic API rejects (#1475). Collapse both case variants down to + * one canonical lowercase header carrying a single value. Same for + * `anthropic-beta` (joined comma-list, deduped). Mutates `headers`. + */ +export function normalizeAnthropicHeaderVariants(headers: Record): void { + // Only collapse when BOTH case variants are present simultaneously — that is the + // only situation undici merges into a rejected `"v, v"` value. A lone variant is + // sent fine on its own, so leave it untouched (preserving the caller's casing + // instead of silently rewriting it to lowercase). + if ("anthropic-version" in headers && "Anthropic-Version" in headers) { + const versionValues = uniqueCommaValues([ + headers["anthropic-version"], + headers["Anthropic-Version"], + ]); + delete headers["Anthropic-Version"]; + delete headers["anthropic-version"]; + if (versionValues.length > 0) { + headers["anthropic-version"] = versionValues[0]; + } + } + + if ("anthropic-beta" in headers && "Anthropic-Beta" in headers) { + const betaValues = uniqueCommaValues([headers["anthropic-beta"], headers["Anthropic-Beta"]]); + delete headers["Anthropic-Beta"]; + delete headers["anthropic-beta"]; + if (betaValues.length > 0) { + headers["anthropic-beta"] = betaValues.join(","); + } + } +} + export const CLAUDE_CLI_VERSION = "2.1.187"; export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`; export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.94.0"; diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index a2d9f8c608..b281a94e35 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -168,6 +168,7 @@ export const AUDIO_SPEECH_PROVIDERS: Record = { authHeader: "bearer", format: "vertex-gemini-tts", models: [ + { id: "gemini-3.1-flash-tts-preview", name: "Gemini 3.1 Flash TTS (Vertex)" }, { id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS (Vertex)" }, { id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS (Vertex)" }, ], diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 484e7eb2de..4a5f760b94 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -240,7 +240,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "kiro", modelId: "claude-opus-4.7", displayName: "Claude Opus 4.7", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, { provider: "kiro", modelId: "claude-opus-4.6", displayName: "Claude Opus 4.6", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, { provider: "kiro", modelId: "claude-sonnet-4.6", displayName: "Claude Sonnet 4.6", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, - { provider: "kiro", modelId: "claude-sonnet-4.5", displayName: "Claude Sonnet 4.5", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, { provider: "kiro", modelId: "claude-haiku-4.5", displayName: "Claude Haiku 4.5", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, { provider: "kiro", modelId: "deepseek-3.2", displayName: "DeepSeek V3.2", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, { provider: "kiro", modelId: "minimax-m2.5", displayName: "MiniMax M2.5", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index a32ce55638..6f722da03f 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -36,6 +36,15 @@ interface ImageModelAliasEntry { description?: string; } +interface ImageCatalogModelEntry { + id: string; + name: string; + provider: string; + supportedSizes: string[]; + inputModalities: string[]; + description?: string; +} + const IMAGE_MODEL_ALIASES: Record = { "gemini-3.1-flash-image-preview": { provider: "antigravity", @@ -590,34 +599,47 @@ export function parseImageModel(modelStr) { /** * Get all image models as a flat list */ -export function getAllImageModels() { - const models = []; - for (const [providerId, config] of Object.entries(IMAGE_PROVIDERS)) { - for (const model of config.models) { - models.push({ - id: `${providerId}/${model.id}`, - name: model.name, - provider: providerId, - supportedSizes: config.supportedSizes, - inputModalities: model.inputModalities || ["text"], - description: model.description || undefined, - }); - } - } - for (const [alias, target] of Object.entries(IMAGE_MODEL_ALIASES)) { - if (!target.listInCatalog) continue; - const providerConfig = IMAGE_PROVIDERS[target.provider]; - const modelConfig = findImageModelConfig(target.provider, target.model); - models.push({ - id: alias, - name: target.name || modelConfig?.name || alias, - provider: target.provider, - supportedSizes: providerConfig?.supportedSizes || [], - inputModalities: target.inputModalities || modelConfig?.inputModalities || ["text"], - description: target.description || modelConfig?.description || undefined, - }); - } - return models; +function imageProviderCatalogEntries( + providerId: string, + config: ImageProviderConfig +): ImageCatalogModelEntry[] { + return config.models.map((model) => ({ + id: `${providerId}/${model.id}`, + name: model.name, + provider: providerId, + supportedSizes: config.supportedSizes, + inputModalities: model.inputModalities || ["text"], + description: model.description || undefined, + })); +} + +function imageAliasCatalogEntry( + alias: string, + target: ImageModelAliasEntry +): ImageCatalogModelEntry | null { + if (!target.listInCatalog) return null; + + const providerConfig = IMAGE_PROVIDERS[target.provider]; + const modelConfig = findImageModelConfig(target.provider, target.model); + return { + id: alias, + name: target.name || modelConfig?.name || alias, + provider: target.provider, + supportedSizes: providerConfig?.supportedSizes || [], + inputModalities: target.inputModalities || modelConfig?.inputModalities || ["text"], + description: target.description || modelConfig?.description || undefined, + }; +} + +export function getAllImageModels(): ImageCatalogModelEntry[] { + const providerModels = Object.entries(IMAGE_PROVIDERS).flatMap(([providerId, config]) => + imageProviderCatalogEntries(providerId, config) + ); + const aliasModels = Object.entries(IMAGE_MODEL_ALIASES).flatMap(([alias, target]) => { + const entry = imageAliasCatalogEntry(alias, target); + return entry ? [entry] : []; + }); + return [...providerModels, ...aliasModels]; } export function getImageModelAliases() { diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index d02172990f..3d6daeb7fb 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -142,6 +142,7 @@ import { bailian_coding_planProvider } from "./registry/bailian-coding-plan/inde import { gigachatProvider } from "./registry/gigachat/index.ts"; import { devin_cliProvider } from "./registry/devin-cli/index.ts"; import { chutesProvider } from "./registry/chutes/index.ts"; +import { factoryProvider } from "./registry/factory/index.ts"; import { databricksProvider } from "./registry/databricks/index.ts"; import { rekaProvider } from "./registry/reka/index.ts"; import { vercel_ai_gatewayProvider } from "./registry/vercel-ai-gateway/index.ts"; @@ -167,8 +168,10 @@ import { kiroProvider } from "./registry/kiro/index.ts"; import { openadapterProvider } from "./registry/openadapter/index.ts"; import { ditProvider } from "./registry/dit/index.ts"; import { tokenrouterProvider } from "./registry/tokenrouter/index.ts"; +import { grok_cliProvider } from "./registry/grok-cli/index.ts"; import { codebuddy_cnProvider } from "./registry/codebuddy-cn/index.ts"; import { pioneerProvider } from "./registry/pioneer/index.ts"; +import { zenmux_freeProvider } from "./registry/zenmux-free/index.ts"; export const REGISTRY: Record = { aimlapi: aimlapiProvider, @@ -310,6 +313,7 @@ export const REGISTRY: Record = { gigachat: gigachatProvider, "devin-cli": devin_cliProvider, chutes: chutesProvider, + factory: factoryProvider, databricks: databricksProvider, reka: rekaProvider, "vercel-ai-gateway": vercel_ai_gatewayProvider, @@ -338,6 +342,8 @@ export const REGISTRY: Record = { openadapter: openadapterProvider, dit: ditProvider, tokenrouter: tokenrouterProvider, + "grok-cli": grok_cliProvider, "codebuddy-cn": codebuddy_cnProvider, pioneer: pioneerProvider, + "zenmux-free": zenmux_freeProvider, }; diff --git a/open-sse/config/providers/registry/blackbox/index.ts b/open-sse/config/providers/registry/blackbox/index.ts index db0f2e26ff..e5fba3d9f8 100644 --- a/open-sse/config/providers/registry/blackbox/index.ts +++ b/open-sse/config/providers/registry/blackbox/index.ts @@ -10,11 +10,15 @@ export const blackboxProvider: RegistryEntry = { authType: "apikey", authHeader: "bearer", models: [ - { id: "gpt-4o", name: "GPT-4o" }, - { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, - { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, - { id: "deepseek-v3", name: "DeepSeek V3" }, - { id: "blackboxai", name: "Blackbox AI" }, - { id: "blackboxai-pro", name: "Blackbox AI Pro" }, + { id: "claude-fable-5", name: "Claude Fable 5" }, + { id: "claude-opus-4.8", name: "Claude Opus 4.8" }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { id: "gpt-5.5", name: "GPT-5.5" }, + { id: "gpt-5.4-pro", name: "GPT-5.4 Pro" }, + { id: "gpt-5.4", name: "GPT-5.4" }, + { id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { id: "gpt-5.4-nano", name: "GPT-5.4 Nano" }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { id: "grok-4.3", name: "Grok 4.3" }, ], }; diff --git a/open-sse/config/providers/registry/cline/index.ts b/open-sse/config/providers/registry/cline/index.ts index fb1ba95696..d1467fdd9f 100644 --- a/open-sse/config/providers/registry/cline/index.ts +++ b/open-sse/config/providers/registry/cline/index.ts @@ -27,6 +27,14 @@ export const clineProvider: RegistryEntry = { { id: "openai/gpt-5.5", name: "GPT-5.5" }, { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, + // #3321 — free OpenRouter-served models Cline exposes; were missing from the picker. + { id: "minimax/minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true }, + { + id: "nvidia/nemotron-3-ultra-550b-a55b", + name: "Nemotron 3 Ultra 550B", + contextLength: 1048576, + supportsReasoning: true, + }, ], passthroughModels: true, }; diff --git a/open-sse/config/providers/registry/factory/index.ts b/open-sse/config/providers/registry/factory/index.ts new file mode 100644 index 0000000000..1cfd208f22 --- /dev/null +++ b/open-sse/config/providers/registry/factory/index.ts @@ -0,0 +1,33 @@ +import type { RegistryEntry } from "../../shared.ts"; + +// Factory AI ("Factory Droids") — the hosted subscription gateway behind the +// local `droid` CLI. OmniRoute already integrates Droid as a CLI tool at +// `src/app/api/cli-tools/droid-settings/route.ts` (see PR #4682); this entry +// adds the same backend as a first-class routing provider so users with a paid +// Factory Droids subscription can proxy traffic through OmniRoute. +// +// Auth surface (per https://github.com/Factory-AI/droid-sdk-typescript): the +// upstream SDK reads its API key from a `FACTORY_API_KEY` env var and falls +// back to stored CLI credentials only when omitted. OmniRoute does NOT read +// that env var — like every gateway since v3.8.0, the key is supplied from the +// Dashboard connection credential. Factory has not (yet) +// published a public OAuth/refresh-token endpoint, so this entry ships with +// `authType: "apikey"`. An OAuth variant can be layered in later by adding +// `src/lib/oauth/providers/factory.ts` and switching `authType` here once +// Factory exposes token endpoints — the auth type toggle is the only change +// required (registry lookups are by-id and the executor is the same). +export const factoryProvider: RegistryEntry = { + id: "factory", + alias: "factory", + format: "openai", + executor: "default", + baseUrl: "https://api.factory.ai/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + // `auto` is Factory's routing sentinel: the gateway picks the best model + // for the prompt (mirrors Droid's `auto` preset). Concrete model IDs can + // be added here once Factory publishes a stable public model list; the + // `passthroughModels` flag in the gateways catalog lets `GET /v1/models` + // reflect whatever Factory's `/v1/models` returns live. + models: [{ id: "auto", name: "Factory Auto (best model)" }], +}; diff --git a/open-sse/config/providers/registry/gemini/index.ts b/open-sse/config/providers/registry/gemini/index.ts index 2db11dc52a..1c1f64b343 100644 --- a/open-sse/config/providers/registry/gemini/index.ts +++ b/open-sse/config/providers/registry/gemini/index.ts @@ -58,6 +58,7 @@ export const geminiProvider: RegistryEntry = { toolCalling: true, supportsVision: true, }, + { id: "gemini-3.1-flash-tts-preview", name: "Gemini 3.1 Flash TTS" }, { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", toolCalling: true, supportsVision: true }, { id: "gemini-2.5-flash", diff --git a/open-sse/config/providers/registry/grok-cli/index.ts b/open-sse/config/providers/registry/grok-cli/index.ts new file mode 100644 index 0000000000..a255e68bbf --- /dev/null +++ b/open-sse/config/providers/registry/grok-cli/index.ts @@ -0,0 +1,22 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { resolvePublicCred } from "../../shared.ts"; + +export const grok_cliProvider: RegistryEntry = { + id: "grok-cli", + alias: "gc", + format: "openai", + executor: "grok-cli", + baseUrl: "https://cli-chat-proxy.grok.com/v1/chat/completions", + authType: "oauth", + authHeader: "bearer", + passthroughModels: true, + models: [ + { id: "grok-build", name: "Grok Build", contextLength: 128000 }, + { id: "grok-composer-2.5-fast", name: "Grok Composer 2.5 Fast", contextLength: 128000 }, + ], + oauth: { + clientIdEnv: "GROK_OAUTH_CLIENT_ID", + clientIdDefault: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"), + tokenUrl: "https://auth.x.ai/oauth2/token", + }, +}; diff --git a/open-sse/config/providers/registry/kiro/index.ts b/open-sse/config/providers/registry/kiro/index.ts index aa112d1146..b3a9792264 100644 --- a/open-sse/config/providers/registry/kiro/index.ts +++ b/open-sse/config/providers/registry/kiro/index.ts @@ -47,13 +47,6 @@ export const kiroProvider: RegistryEntry = { contextLength: 200000, maxOutputTokens: 64000, }, - // models for kiro free tier - { - id: "claude-sonnet-4.5", - name: "Claude Sonnet 4.5", - contextLength: 200000, - maxOutputTokens: 64000, - }, { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", diff --git a/open-sse/config/providers/registry/minimax/cn/index.ts b/open-sse/config/providers/registry/minimax/cn/index.ts index 675d9e61a6..2274b01c39 100644 --- a/open-sse/config/providers/registry/minimax/cn/index.ts +++ b/open-sse/config/providers/registry/minimax/cn/index.ts @@ -7,6 +7,7 @@ export const minimax_cnProvider: RegistryEntry = { format: "claude", executor: "default", baseUrl: "https://api.minimaxi.com/anthropic/v1/messages", + modelsUrl: "https://api.minimaxi.com/v1/models", urlSuffix: "?beta=true", authType: "apikey", authHeader: "bearer", diff --git a/open-sse/config/providers/registry/minimax/index.ts b/open-sse/config/providers/registry/minimax/index.ts index 136c2d8e41..3033fccb46 100644 --- a/open-sse/config/providers/registry/minimax/index.ts +++ b/open-sse/config/providers/registry/minimax/index.ts @@ -7,6 +7,7 @@ export const minimaxProvider: RegistryEntry = { format: "claude", executor: "default", baseUrl: "https://api.minimax.io/anthropic/v1/messages", + modelsUrl: "https://api.minimax.io/v1/models", urlSuffix: "?beta=true", authType: "apikey", authHeader: "bearer", diff --git a/open-sse/config/providers/registry/zenmux-free/index.ts b/open-sse/config/providers/registry/zenmux-free/index.ts new file mode 100644 index 0000000000..126e2bf203 --- /dev/null +++ b/open-sse/config/providers/registry/zenmux-free/index.ts @@ -0,0 +1,41 @@ +import type { RegistryEntry } from "../../shared.ts"; + +/** + * ZenMux Free — session-cookie free-tier gateway. + * + * Users log into zenmux.ai, export all cookies via a browser extension + * (EditThisCookie / Cookie-Editor), and paste the full Cookie header string + * as the credential. The ctoken extracted from the cookie string is required + * for all API requests as a query parameter. + * + * Models available on the free tier (5 Flows/5h, 38.64 Flows/week): + * DeepSeek V3.2, GLM 4.7 Flash Free, MiMo V2 Flash Free, and others. + * + * Short alias "zmf" is distinct from the paid "zenmux" (alias "zm") which + * uses API-key auth against the OpenAI-compatible endpoint. + */ +export const zenmux_freeProvider: RegistryEntry = { + id: "zenmux-free", + alias: "zmf", + format: "openai", + executor: "zenmux-free", + baseUrl: "https://zenmux.ai/api/anthropic/v1/messages", + authType: "apikey", + authHeader: "cookie", + models: [ + { id: "deepseek/deepseek-chat", name: "DeepSeek V3.2 (Non-thinking)" }, + { id: "deepseek/deepseek-reasoner", name: "DeepSeek V3.2 (Thinking)", supportsReasoning: true }, + { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, + { id: "kuaishou/kat-coder-pro-v1-free", name: "KAT Coder Pro V1 Free" }, + { id: "xiaomi/mimo-v2-flash-free", name: "MiMo V2 Flash Free" }, + { id: "z-ai/glm-4.7-flash-free", name: "GLM 4.7 Flash Free" }, + { id: "stepfun/step-3.5-flash-free", name: "Step 3.5 Flash Free" }, + { id: "inclusionai/ling-1t", name: "Ling 1T" }, + { id: "inclusionai/ling-mini-2.0", name: "Ling Mini 2.0" }, + { id: "inclusionai/ring-1t", name: "Ring 1T" }, + { id: "sapiens-ai/agnes-1.5-lite", name: "Agnes 1.5 Lite" }, + { id: "sapiens-ai/agnes-1.5-pro", name: "Agnes 1.5 Pro" }, + ], +}; + +export default zenmux_freeProvider; diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 0447b7bb23..5c0d7e5fb1 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -126,6 +126,12 @@ export interface RegistryEntry { defaultContextLength?: number; /** Optional session pool config for rate limit management */ poolConfig?: Record; + /** + * When true, the provider rejects non-streaming requests (HTTP 400). + * resolveStreamFlag will keep streaming even when the client requests JSON; + * OmniRoute accumulates the stream and converts it to a JSON body for the client. (#2081) + */ + forceStream?: boolean; } export interface LegacyProvider { diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index af1f3652b4..442699b047 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -192,6 +192,19 @@ export const VIDEO_PROVIDERS: Record = { format: "runwayml", models: RUNWAYML_SUPPORTED_VIDEO_MODELS, }, + + alibaba: { + id: "alibaba", + alias: "ali", + // DashScope (Alibaba Cloud Model Studio) async video-synthesis API. Reuses + // the stored alibaba provider Bearer apiKey — no separate credential flow. + baseUrl: "https://dashscope-intl.aliyuncs.com/api/v1", + statusUrl: "https://dashscope-intl.aliyuncs.com/api/v1/tasks", + authType: "apikey", + authHeader: "bearer", + format: "dashscope-video", + models: [{ id: "wan2.7-t2v", name: "Wan 2.7 T2V" }], + }, }; /** diff --git a/open-sse/executors/azure-openai.ts b/open-sse/executors/azure-openai.ts index a902383a09..01c68cf088 100644 --- a/open-sse/executors/azure-openai.ts +++ b/open-sse/executors/azure-openai.ts @@ -1,4 +1,5 @@ import { DefaultExecutor } from "./default.ts"; +import type { ProviderCredentials } from "./base.ts"; import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; const DEFAULT_API_VERSION = "2024-12-01-preview"; @@ -17,7 +18,12 @@ export class AzureOpenAIExecutor extends DefaultExecutor { super("azure-openai"); } - buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) { + buildUrl( + model: string, + stream: boolean, + urlIndex = 0, + credentials: ProviderCredentials | null = null + ) { void urlIndex; const providerSpecificData = credentials?.providerSpecificData || {}; @@ -29,7 +35,7 @@ export class AzureOpenAIExecutor extends DefaultExecutor { return `${baseUrl}/openai/deployments/${encodeURIComponent(model)}/chat/completions?api-version=${encodeURIComponent(apiVersion)}`; } - buildHeaders(credentials: any, stream = true) { + buildHeaders(credentials: ProviderCredentials | null, stream = true) { const apiKey = credentials?.apiKey || credentials?.accessToken || ""; const headers: Record = { "Content-Type": "application/json", diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 7d36eb7e1d..a0c8265feb 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,5 +1,8 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; -import { mergeClientAnthropicBeta } from "../config/anthropicHeaders.ts"; +import { + mergeClientAnthropicBeta, + normalizeAnthropicHeaderVariants, +} from "../config/anthropicHeaders.ts"; import { applyContextEditingToBody } from "../config/contextEditing.ts"; import { findOffendingField, stripGroqUnsupportedFields } from "../config/providerFieldStrips.ts"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; @@ -576,6 +579,8 @@ export class BaseExecutor { headers["Accept"] = stream ? "text/event-stream" : "application/json"; + normalizeAnthropicHeaderVariants(headers); + return headers; } diff --git a/open-sse/executors/codebuddy-cn.ts b/open-sse/executors/codebuddy-cn.ts index 83840ca254..359eaa016c 100644 --- a/open-sse/executors/codebuddy-cn.ts +++ b/open-sse/executors/codebuddy-cn.ts @@ -10,10 +10,11 @@ import type { ProviderCredentials } from "./base.ts"; * as the client sent it, so we force it true here — OmniRoute still re-aggregates * the SSE into a JSON response for non-streaming clients. * - * CodeBuddy CN only surfaces model reasoning when the request carries the - * official CLI's OpenAI-style params: reasoning_effort + reasoning_summary:"auto". - * Mirror the CLI here. When the caller explicitly asks for "none"/"off" we drop - * the field entirely (the gateway has no "none" value). + * Reasoning params are opt-in: reasoning_summary:"auto" is only added when the + * client explicitly sets reasoning_effort. Plain requests are left untouched. + * When the caller explicitly asks for "none"/"off" we drop the field entirely + * (the gateway has no "none" value). Forcing reasoning on plain requests trips + * CodeBuddy's content filter and returns an error. */ export class CodeBuddyCnExecutor extends DefaultExecutor { constructor() { @@ -37,10 +38,14 @@ export class CodeBuddyCnExecutor extends DefaultExecutor { if (eff === "none" || eff === "off") { // Gateway has no "none" — just omit. Do NOT set reasoning_summary. delete out.reasoning_effort; - } else { - if (!eff) out.reasoning_effort = "medium"; + } else if (eff) { + // Client explicitly asked for reasoning — mirror the CLI's reasoning_summary + // so CodeBuddy surfaces the model's reasoning. out.reasoning_summary = "auto"; } + // No reasoning requested: leave both unset. Forcing reasoning_effort:"medium" + // + reasoning_summary on plain requests makes CodeBuddy trip its content + // filter and return an error. return out; } } diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 58354f2384..62cf10f348 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -51,6 +51,12 @@ import { import { getCursorVersion } from "../utils/cursorVersionDetector.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { generateToolCallId } from "../translator/helpers/toolCallHelper.ts"; +import { + parseComposerToolCalls, + createStreamingState, + feedStreamingChunk, + type StreamingState as ComposerStreamingState, +} from "../utils/composerToolCalls.ts"; import { cursorSessionManager, type CursorSession } from "../services/cursorSessionManager.ts"; import crypto from "crypto"; import * as fs from "node:fs"; @@ -403,6 +409,14 @@ export type StreamCtx = { // the visible suffix (after the last ``) has already been streamed // out as `content` deltas, so we only emit the incremental tail per frame. composerVisibleEmittedLength: number; + // Composer DeepSeek-format inline tool-call parser state (decolua/9router#1335). + // Null for non-Composer models (no overhead). When set, the streaming parser + // holds back text inside `<|tool▁calls▁begin|>...<|tool▁calls▁end|>` markers + // and emits structured tool_calls SSE chunks once the block closes. + composerToolParserState: ComposerStreamingState | null; + // True once we've emitted structured tool_calls from the inline Composer parser + // (to avoid double-emitting if the block appears in multiple accumulated frames). + composerInlineToolCallsEmitted: boolean; }; export function newStreamCtx(model: string, emit: (chunk: string) => void): StreamCtx { @@ -423,6 +437,8 @@ export function newStreamCtx(model: string, emit: (chunk: string) => void): Stre toolCalls: [], pendingToolCalls: new Map(), composerVisibleEmittedLength: 0, + composerToolParserState: isComposerModel(model) ? createStreamingState() : null, + composerInlineToolCallsEmitted: false, }; } @@ -645,10 +661,45 @@ export function processFrame( if (isComposerModel(ctx.model)) { const visible = visibleComposerContentFromThinking(ctx.thinkingText); if (visible.length > ctx.composerVisibleEmittedLength) { - const deltaContent = visible.slice(ctx.composerVisibleEmittedLength); - ctx.composerVisibleEmittedLength = visible.length; - ctx.totalText += deltaContent; - emitChunk(ctx, { content: deltaContent }); + // Feed the full accumulated visible text into the DeepSeek inline + // tool-call streaming parser (decolua/9router#1335). It tracks how + // much has already been safely emitted and returns only the new + // safe delta — i.e. text that precedes any `<|tool▁calls▁begin|>` + // marker (or a partial prefix of one). When the closing marker + // arrives, it sets ready=true and provides the parsed tool_calls. + if (ctx.composerToolParserState) { + const parseOut = feedStreamingChunk(ctx.composerToolParserState, visible); + // composerVisibleEmittedLength tracks what the parser has "emitted" + // — stays in sync via state.emitted. + ctx.composerVisibleEmittedLength = ctx.composerToolParserState.emitted; + if (parseOut.safeDelta) { + ctx.totalText += parseOut.safeDelta; + emitChunk(ctx, { content: parseOut.safeDelta }); + } + if (parseOut.ready && parseOut.toolCalls.length > 0 && !ctx.composerInlineToolCallsEmitted) { + ctx.composerInlineToolCallsEmitted = true; + for (const tc of parseOut.toolCalls) { + const toolCallIndex = ctx.emittedToolCallIndex++; + ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments }); + emitChunk(ctx, { + tool_calls: [ + { + index: toolCallIndex, + id: tc.id, + type: "function", + function: { name: tc.function.name, arguments: tc.function.arguments }, + }, + ], + }); + } + } + } else { + // Non-composer or state not initialised — fall back to direct emit. + const deltaContent = visible.slice(ctx.composerVisibleEmittedLength); + ctx.composerVisibleEmittedLength = visible.length; + ctx.totalText += deltaContent; + emitChunk(ctx, { content: deltaContent }); + } } } else { emitChunk(ctx, { reasoning_content: d.text }); @@ -1378,6 +1429,39 @@ export class CursorExecutor extends BaseExecutor { // one delta before finish. emitChunk(ctx, { role: "assistant", content: "" }); } + + // End-of-stream Composer inline tool-call fallback (decolua/9router#1335): + // if the entire response arrived as a single big chunk (or the streaming + // parser state never reached "ready"), try a full non-streaming parse on + // the accumulated visible content so we still emit structured tool_calls + // and don't leak the markers as plain text. + if ( + isComposerModel(ctx.model) && + !ctx.composerInlineToolCallsEmitted && + ctx.totalText + ) { + const parsed = parseComposerToolCalls(ctx.totalText); + if (parsed.toolCalls.length > 0) { + ctx.composerInlineToolCallsEmitted = true; + // Replace totalText with the residual (markers stripped). + ctx.totalText = parsed.content; + for (const tc of parsed.toolCalls) { + const toolCallIndex = ctx.emittedToolCallIndex++; + ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments }); + emitChunk(ctx, { + tool_calls: [ + { + index: toolCallIndex, + id: tc.id, + type: "function", + function: { name: tc.function.name, arguments: tc.function.arguments }, + }, + ], + }); + } + } + } + // OpenAI finish_reason: "tool_calls" if the model invoked any declared // tool, else "stop". A turn with mixed text + tool_calls finishes with // "tool_calls" (the tool calls are the actionable signal for the client). @@ -1413,6 +1497,25 @@ export class CursorExecutor extends BaseExecutor { // Non-streaming: chat.completion shape. Include tool_calls in the // assistant message when the model invoked any (Phase 5). + + // Composer DeepSeek inline tool-call fallback (decolua/9router#1335): for + // non-streaming requests, the streaming parser never runs — parse the + // accumulated visible content once here instead. + if ( + isComposerModel(ctx.model) && + !ctx.composerInlineToolCallsEmitted && + ctx.totalText + ) { + const parsed = parseComposerToolCalls(ctx.totalText); + if (parsed.toolCalls.length > 0) { + ctx.composerInlineToolCallsEmitted = true; + ctx.totalText = parsed.content; + for (const tc of parsed.toolCalls) { + ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments }); + } + } + } + const usage = buildCursorUsage(ctx, body); const finishReason = ctx.toolCalls.length > 0 ? "tool_calls" : "stop"; const message: { diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 8b76b4c486..eb60f95fa5 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -14,7 +14,10 @@ import { } from "../services/claudeCodeCompatible.ts"; import { getGigachatAccessToken } from "../services/gigachatAuth.ts"; import { getRegistryEntry } from "../config/providerRegistry.ts"; -import { mergeClientAnthropicBeta } from "../config/anthropicHeaders.ts"; +import { + mergeClientAnthropicBeta, + normalizeAnthropicHeaderVariants, +} from "../config/anthropicHeaders.ts"; import { isOfficialAnthropicBaseUrl } from "../utils/anthropicHost.ts"; import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; import { stripUnsupportedParams } from "../translator/paramSupport.ts"; @@ -573,6 +576,8 @@ export class DefaultExecutor extends BaseExecutor { } } + normalizeAnthropicHeaderVariants(headers); + return headers; } @@ -615,6 +620,22 @@ export class DefaultExecutor extends BaseExecutor { return { ...record, messages, response_format: { type: "json_object" } } as T; } + // Some Responses-compatible upstreams (e.g. LM Studio) reject a request whose + // `text` is an object missing `text.format` with a 400 missing_required_parameter. + // The Responses API default for that field is { type: "text" }, so default it + // for openai-compatible "responses" providers before forwarding upstream. + defaultResponsesTextFormat(body: T): T { + if (!this.provider?.startsWith?.("openai-compatible-")) return body; + if (!this.provider.includes("responses")) return body; + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const record = body as Record; + const text = record.text; + if (!text || typeof text !== "object" || Array.isArray(text)) return body; + const textRecord = text as Record; + if (textRecord.format !== undefined) return body; + return { ...record, text: { ...textRecord, format: { type: "text" } } } as T; + } + /** * For compatible providers, the model name is already clean by the time * it reaches the executor (chatCore sets body.model = modelInfo.model, @@ -627,6 +648,26 @@ export class DefaultExecutor extends BaseExecutor { const cleanedBody = super.transformRequest(model, body, stream, credentials); let withDefaults = applyProviderRequestDefaults(cleanedBody, this.config.requestDefaults); withDefaults = this.applyJsonSchemaFallback(withDefaults); + withDefaults = this.defaultResponsesTextFormat(withDefaults); + + // Port of decolua/9router commit d652300e: + // Cerebras returns 400 (wrong_api_format) and Mistral returns 422 + // (extra_forbidden) when the forwarded body carries `client_metadata` + // (an OpenAI Codex / Claude CLI passthrough field with no equivalent on + // these upstreams). Strip it before sending downstream. Other providers + // (notably `openai` / `codex`) intentionally keep it. + if ( + withDefaults && + typeof withDefaults === "object" && + !Array.isArray(withDefaults) && + (this.provider === "cerebras" || this.provider === "mistral") && + Object.prototype.hasOwnProperty.call(withDefaults, "client_metadata") + ) { + const withoutClientMetadata = { ...(withDefaults as Record) }; + delete withoutClientMetadata.client_metadata; + withDefaults = withoutClientMetadata; + } + const targetFormat = getTargetFormat(this.provider, credentials?.providerSpecificData); const requestFormat = withDefaults && typeof withDefaults === "object" && !Array.isArray(withDefaults) diff --git a/open-sse/executors/grok-cli.ts b/open-sse/executors/grok-cli.ts new file mode 100644 index 0000000000..5f5f870251 --- /dev/null +++ b/open-sse/executors/grok-cli.ts @@ -0,0 +1,244 @@ +/** + * GrokCliExecutor — Grok Build Provider + * + * Routes requests through Grok's chat proxy endpoint using OAuth authentication. + * Uses Node.js https module directly with IPv4 forced to bypass Cloudflare blocking. + * Supports automatic token refresh via refresh_token. + */ + +import { + BaseExecutor, + type ExecuteInput, + type ExecutorLog, + type ProviderCredentials, +} from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; +import { resolvePublicCred } from "../utils/publicCreds.ts"; +import https from "node:https"; + +const GROK_TOKEN_URL = "https://auth.x.ai/oauth2/token"; +const REQUEST_TIMEOUT_MS = 60_000; + +export class GrokCliExecutor extends BaseExecutor { + constructor() { + super("grok-cli", PROVIDERS["grok-cli"]); + } + + async execute(input: ExecuteInput) { + const { model, body, stream, credentials, signal } = input; + + const url = this.buildUrl(model, stream, 0, credentials); + const headers = this.buildHeaders(credentials, stream); + const transformedBody = this.transformRequest(model, body, stream, credentials); + const bodyStr = JSON.stringify(transformedBody); + + const response = await this.nativePost(url, headers, bodyStr, signal); + return { response, url, headers, transformedBody }; + } + + async refreshCredentials( + credentials: ProviderCredentials, + log?: ExecutorLog | null + ): Promise | null> { + if (!credentials?.refreshToken) { + log?.warn?.("TOKEN_REFRESH", "Grok Build: no refresh token available"); + return null; + } + + const clientId = resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"); + + try { + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: credentials.refreshToken, + }); + + const result = await this.nativeHttpsPost( + GROK_TOKEN_URL, + { + "Content-Type": "application/x-www-form-urlencoded", + }, + body.toString(), + 10_000 + ); + + if (result.status !== 200) { + log?.warn?.("TOKEN_REFRESH", `Grok Build: refresh failed with status ${result.status}`); + return null; + } + + const data = JSON.parse(result.body); + if (!data.access_token) { + log?.warn?.("TOKEN_REFRESH", "Grok Build: no access_token in refresh response"); + return null; + } + + const expiresIn = data.expires_in || 21600; + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); + + log?.info?.("TOKEN_REFRESH", `Grok Build: token refreshed, expires ${expiresAt}`); + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || credentials.refreshToken, + expiresAt, + }; + } catch (error) { + log?.warn?.( + "TOKEN_REFRESH", + `Grok Build: refresh error: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + } + + private nativeHttpsPost( + url: string, + headers: Record, + bodyStr: string, + timeoutMs = 10_000 + ): Promise<{ status: number; body: string }> { + const urlObj = new URL(url); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => req.destroy(new Error("Timeout")), timeoutMs); + + const req = https.request( + { + hostname: urlObj.hostname, + port: 443, + path: urlObj.pathname + urlObj.search, + method: "POST", + family: 4, + headers: { + ...headers, + "Content-Length": Buffer.byteLength(bodyStr), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + clearTimeout(timer); + resolve({ + status: res.statusCode ?? 500, + body: Buffer.concat(chunks).toString("utf-8"), + }); + }); + } + ); + + req.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + req.write(bodyStr); + req.end(); + }); + } + + private nativePost( + url: string, + headers: Record, + bodyStr: string, + signal?: AbortSignal | null + ): Promise { + const urlObj = new URL(url); + + if (signal?.aborted) { + return Promise.reject(new Error("Aborted")); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => req.destroy(new Error("Timeout")), REQUEST_TIMEOUT_MS); + let settled = false; + + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + }; + + const req = https.request( + { + hostname: urlObj.hostname, + port: 443, + path: urlObj.pathname + urlObj.search, + method: "POST", + family: 4, + headers: { + ...headers, + "Content-Length": Buffer.byteLength(bodyStr), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + settle(() => { + const responseBody = Buffer.concat(chunks).toString("utf-8"); + const responseHeaders: Record = {}; + for (const [key, value] of Object.entries(res.headers)) { + if (typeof value === "string") responseHeaders[key] = value; + else if (Array.isArray(value)) responseHeaders[key] = value.join(", "); + } + resolve( + new Response(responseBody, { + status: res.statusCode ?? 500, + headers: responseHeaders, + }) + ); + }); + }); + } + ); + + if (signal) { + const onAbort = () => settle(() => reject(new Error("Aborted"))); + signal.addEventListener("abort", onAbort, { once: true }); + // Clean up listener when request finishes naturally + req.on("close", () => signal.removeEventListener("abort", onAbort)); + } + + req.on("error", (err) => settle(() => reject(err))); + req.write(bodyStr); + req.end(); + }); + } + + buildHeaders(credentials: ProviderCredentials, stream = true) { + const headers: Record = { + "Content-Type": "application/json", + }; + + if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } else if (credentials.apiKey) { + headers["Authorization"] = `Bearer ${credentials.apiKey}`; + } + + headers["Accept"] = stream ? "text/event-stream" : "application/json"; + headers["x-grok-client-version"] = "0.2.64"; + headers["x-grok-client-identifier"] = "grok_cli_rs"; + headers["User-Agent"] = "grok-cli/0.2.64 (Windows 10.0.26200; x64)"; + + return headers; + } + + transformRequest( + model: string, + body: unknown, + stream: boolean, + _credentials: ProviderCredentials + ) { + const transformed = + body && typeof body === "object" ? { ...(body as Record) } : {}; + if (!transformed.model) { + transformed.model = model || "grok-composer-2.5-fast"; + } + transformed.stream = !!stream; + return transformed; + } +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 3dc638711f..e01751ade6 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -47,12 +47,15 @@ import { V0VercelWebExecutor } from "./v0-vercel-web.ts"; import { KimiWebExecutor } from "./kimi-web.ts"; import { DoubaoWebExecutor } from "./doubao-web.ts"; import { QwenWebExecutor } from "./qwen-web.ts"; -import { KimiExecutor } from "./kimi.ts" +import { KimiExecutor } from "./kimi.ts"; import { TheOldLlmExecutor } from "./theoldllm.ts"; import { ChipotleExecutor } from "./chipotle.ts"; import { LMArenaExecutor } from "./lmarena.ts"; import { MimocodeExecutor } from "./mimocode.ts"; +import { GrokCliExecutor } from "./grok-cli.ts"; import { CodeBuddyCnExecutor } from "./codebuddy-cn.ts"; +import { ZenmuxFreeExecutor } from "./zenmux-free.ts"; + const executors = { antigravity: new AntigravityExecutor(), @@ -149,8 +152,12 @@ const executors = { lma: new LMArenaExecutor(), // Alias mimocode: new MimocodeExecutor(), mcode: new MimocodeExecutor(), // Alias + "grok-cli": new GrokCliExecutor(), + gc: new GrokCliExecutor(), // Alias "codebuddy-cn": new CodeBuddyCnExecutor(), cbcn: new CodeBuddyCnExecutor(), // Alias for codebuddy-cn + "zenmux-free": new ZenmuxFreeExecutor(), + zmf: new ZenmuxFreeExecutor(), // Alias for zenmux-free }; const defaultCache = new Map(); @@ -211,4 +218,6 @@ export { TheOldLlmExecutor } from "./theoldllm.ts"; export { ChipotleExecutor } from "./chipotle.ts"; export { LMArenaExecutor } from "./lmarena.ts"; export { MimocodeExecutor } from "./mimocode.ts"; +export { GrokCliExecutor } from "./grok-cli.ts"; export { CodeBuddyCnExecutor } from "./codebuddy-cn.ts"; +export { ZenmuxFreeExecutor } from "./zenmux-free.ts"; diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index 382b11919a..614efbaf3b 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -8,6 +8,7 @@ import { import { PROVIDERS } from "../config/constants.ts"; import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.ts"; +import { splitInlineThinking, flushPendingThinking, type KiroThinkingState } from "./kiroThinking.ts"; type JsonRecord = Record; @@ -34,6 +35,10 @@ type KiroStreamState = { hasContextUsage?: boolean; hasMeteringEvent?: boolean; usage?: UsageSummary; + hasReasoningContent?: boolean; + reasoningChunkCount?: number; + // Inline-thinking splitter state (populated only when thinkingExpected=true). + thinking?: KiroThinkingState; }; type EventFrame = { @@ -338,18 +343,51 @@ export class KiroExecutor extends BaseExecutor { return { response, url, headers, transformedBody }; } - // For Kiro, we need to transform the binary EventStream to SSE - // Create a TransformStream to convert binary to SSE text - const transformedResponse = this.transformEventStreamToSSE(response, model); + // For Kiro, we need to transform the binary EventStream to SSE. + // Create a TransformStream to convert binary to SSE text. + // + // When the user enabled thinking, Claude on Kiro streams its reasoning + // **inline** as `` blocks inside + // `assistantResponseEvent.content` rather than as separate + // `reasoningContentEvent` frames. We pass a hint so the transform stream + // can split that inline reasoning into the OpenAI `delta.reasoning_content` + // channel. + const tb = transformedBody as Record; + const userContent = + ( + ( + ( + (tb?.conversationState as Record) + ?.currentMessage as Record + )?.userInputMessage as Record + )?.content as string + ) || ""; + const thinkingExpected = userContent.includes("enabled"); + const transformedResponse = this.transformEventStreamToSSE(response, model, { thinkingExpected }); return { response: transformedResponse, url, headers, transformedBody }; } /** - * Transform AWS EventStream binary response to SSE text stream - * Using TransformStream instead of ReadableStream.pull() to avoid Workers timeout + * Transform AWS EventStream binary response to SSE text stream. + * Using TransformStream instead of ReadableStream.pull() to avoid Workers timeout. + * + * @param response Upstream raw fetch response (binary EventStream). + * @param model Logical model id (kept in OpenAI chunks for clients). + * @param opts + * @param opts.thinkingExpected When true, scan inbound + * `assistantResponseEvent.content` for inline `` + * blocks and split them into the OpenAI `delta.reasoning_content` channel. + * Required for Claude on Kiro when `enabled` + * is in the system prompt, because Kiro streams reasoning inline rather + * than as separate `reasoningContentEvent` frames. */ - transformEventStreamToSSE(response: Response, model: string) { + transformEventStreamToSSE( + response: Response, + model: string, + opts: { thinkingExpected?: boolean } = {} + ) { + const thinkingExpected = !!opts.thinkingExpected; const buffer = new ByteQueue(); let chunkIndex = 0; const responseId = `chatcmpl-${Date.now()}`; @@ -364,6 +402,9 @@ export class KiroExecutor extends BaseExecutor { seenToolIds: new Map(), toolArgsEmitted: new Map(), toolArgsBuffered: new Map(), + hasReasoningContent: false, + reasoningChunkCount: 0, + thinking: thinkingExpected ? { thinkingMode: false, pendingTag: "" } : undefined, }; const transformStream = new TransformStream( @@ -434,21 +475,75 @@ export class KiroExecutor extends BaseExecutor { } state.totalContentLength += content.length; - const chunk: JsonRecord = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [ - { - index: 0, - delta: chunkIndex === 0 ? { role: "assistant", content } : { content }, - finish_reason: null, + if (thinkingExpected && state.thinking) { + // Claude on Kiro emits reasoning inline as `` + // when `enabled` is in the system prompt. + // Split it into the OpenAI `reasoning_content` channel so downstream + // consumers see the same shape they would get from a native reasoning model. + const thinkingState = state.thinking; + splitInlineThinking( + thinkingState, + content, + (text) => { + if (!text) return; + const chunk: JsonRecord = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: chunkIndex === 0 ? { role: "assistant", content: text } : { content: text }, + finish_reason: null, + }, + ], + }; + chunkIndex++; + controller.enqueue(TEXT_ENCODER.encode(`data: ${JSON.stringify(chunk)}\n\n`)); }, - ], - }; - chunkIndex++; - controller.enqueue(TEXT_ENCODER.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + (reasoning) => { + if (!reasoning) return; + state.hasReasoningContent = true; + const reasoningDelta: JsonRecord = + (state.reasoningChunkCount ?? 0) === 0 && chunkIndex === 0 + ? { role: "assistant", reasoning_content: reasoning } + : { reasoning_content: reasoning }; + const chunk: JsonRecord = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: reasoningDelta, + finish_reason: null, + }, + ], + }; + chunkIndex++; + state.reasoningChunkCount = (state.reasoningChunkCount ?? 0) + 1; + controller.enqueue(TEXT_ENCODER.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + ); + } else { + const chunk: JsonRecord = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: chunkIndex === 0 ? { role: "assistant", content } : { content }, + finish_reason: null, + }, + ], + }; + chunkIndex++; + controller.enqueue(TEXT_ENCODER.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } } // Handle codeEvent @@ -644,6 +739,41 @@ export class KiroExecutor extends BaseExecutor { // idempotent against toolArgsEmitted if messageStopEvent already flushed them. flushBufferedToolArgs(state, controller, { responseId, created, model }); + // Drain any pending inline-thinking tag fragment so we don't drop + // trailing characters when the stream ends mid-tag (e.g. ` { + if (!text) return; + const chunk: JsonRecord = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: text }, finish_reason: null }], + }; + chunkIndex++; + controller.enqueue(TEXT_ENCODER.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + }, + (reasoning) => { + if (!reasoning) return; + const chunk: JsonRecord = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { index: 0, delta: { reasoning_content: reasoning }, finish_reason: null }, + ], + }; + chunkIndex++; + controller.enqueue(TEXT_ENCODER.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + ); + } + // Emit finish chunk if not already sent if (!state.finishEmitted) { state.finishEmitted = true; diff --git a/open-sse/executors/kiroThinking.ts b/open-sse/executors/kiroThinking.ts new file mode 100644 index 0000000000..49ca3e324b --- /dev/null +++ b/open-sse/executors/kiroThinking.ts @@ -0,0 +1,116 @@ +/** + * Inline `` splitter for Claude on Kiro. + * + * Background: + * When `enabled` is in the system prompt, + * Claude on Kiro emits its reasoning **inline** as `` + * blocks inside `assistantResponseEvent.content`, rather than as separate + * `reasoningContentEvent` frames. To match the OpenAI streaming shape that + * downstream translators (Anthropic /thinking_blocks, Claude SSE, etc.) + * expect, we split that inline reasoning back out and route it to the + * `delta.reasoning_content` channel instead of `delta.content`. + * + * The implementation is split into pure functions so it can be unit-tested + * without dragging in the rest of the executor stack (proxy-agent, AWS + * EventStream parser, etc.). The KiroExecutor wires these helpers into its + * TransformStream by passing controller-bound emit callbacks. + * + * Ported from decolua/9router#1273 (kiroThinking.js) by Amin Fathullah. + */ + +/** Mutable state carried across `splitInlineThinking` calls. */ +export type KiroThinkingState = { + /** True while the cursor is inside a `` block. */ + thinkingMode: boolean; + /** + * Characters held back because they might be the start of a tag we'll + * complete on the next slice (e.g. `` state. + * + * State is mutated on `state` so a tag split between frames (e.g. `…foo`) is still recognised. + * + * @param state Mutable state carried across calls. Initialise with + * `{ thinkingMode: false, pendingTag: "" }`. + * @param raw Next slice from `assistantResponseEvent.content`. May be empty + * or null/undefined (no-op). + * @param onContent Called with text that should land in `delta.content`. + * @param onReasoning Called with text that should land in `delta.reasoning_content`. + */ +export function splitInlineThinking( + state: KiroThinkingState, + raw: string | null | undefined, + onContent: (s: string) => void, + onReasoning: (s: string) => void +): void { + let text = (state.pendingTag || "") + (raw || ""); + state.pendingTag = ""; + + // Maximum length of an unfinished tag we might still complete on the next + // frame: `` is the longest at 11 chars. + const PARTIAL_MAX = 11; + + while (text.length > 0) { + const target = state.thinkingMode ? "" : ""; + const idx = text.indexOf(target); + + if (idx === -1) { + // No full target tag in `text`. Look for a possible partial at the end + // so we can complete it on the next frame. + let holdFrom = text.length; + for (let i = Math.max(0, text.length - PARTIAL_MAX); i < text.length; i++) { + const tail = text.slice(i); + if (target.startsWith(tail) && tail.length > 0) { + holdFrom = i; + break; + } + } + const flushable = text.slice(0, holdFrom); + if (flushable) { + if (state.thinkingMode) onReasoning(flushable); + else onContent(flushable); + } + state.pendingTag = text.slice(holdFrom); + return; + } + + // Found a complete target tag. Flush everything before it in the current + // mode, flip the mode, and keep walking the remainder. + const before = text.slice(0, idx); + if (before) { + if (state.thinkingMode) onReasoning(before); + else onContent(before); + } + state.thinkingMode = !state.thinkingMode; + text = text.slice(idx + target.length); + } +} + +/** + * Drain whatever is left in `state.pendingTag` at end-of-stream. Routes the + * leftover characters to whichever channel matches the current + * `state.thinkingMode` so we don't silently lose data when the stream ends + * mid-tag (e.g. ` void, + onReasoning: (s: string) => void +): void { + if (!state.pendingTag) return; + const leftover = state.pendingTag; + state.pendingTag = ""; + if (state.thinkingMode) onReasoning(leftover); + else onContent(leftover); +} diff --git a/open-sse/executors/zenmux-free.ts b/open-sse/executors/zenmux-free.ts new file mode 100644 index 0000000000..6cb83b4351 --- /dev/null +++ b/open-sse/executors/zenmux-free.ts @@ -0,0 +1,283 @@ +/** + * ZenmuxFreeExecutor — ZenMux Free (web-cookie) provider + * + * Accesses ZenMux's free-tier LLM gateway via session cookies exported from + * the browser. Uses ZenMux's Anthropic-compatible SSE endpoint, translating + * the response to OpenAI-format chunks for OmniRoute consumers. + * + * Endpoint: POST https://zenmux.ai/api/anthropic/v1/messages + * Auth: Full cookie header string from zenmux.ai (must include ctoken) + */ +import { randomUUID } from "crypto"; +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../utils/error.ts"; + +const CHAT_URL = "https://zenmux.ai/api/anthropic/v1/messages"; +const USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; + +function extractCtoken(cookieStr: string): string { + const m = cookieStr.match(/ctoken=([^;]+)/); + return m ? m[1] : ""; +} + +export class ZenmuxFreeExecutor extends BaseExecutor { + constructor() { + super("zenmux-free", { id: "zenmux-free", baseUrl: CHAT_URL }); + } + + async execute(input: ExecuteInput) { + const { body, credentials, signal, stream: wantStream } = input; + const bodyObj = (body || {}) as Record; + + const rawCookie = normalizeCookie(String(credentials?.apiKey ?? "").trim()); + const ctoken = extractCtoken(rawCookie); + if (!ctoken) { + return makeErrorResult( + 401, + "ZenMux Free: ctoken not found in cookies. Export all cookies from zenmux.ai and paste as the credential.", + body, + CHAT_URL + ); + } + + const messages = ( + bodyObj.messages as Array<{ role: string; content: unknown }> + ) || []; + const modelId = (bodyObj.model as string) || "deepseek/deepseek-chat"; + const maxTokens = (bodyObj.max_tokens as number) || 4096; + + // Flatten messages into a single user text to accommodate ZenMux's + // Anthropic-compatible endpoint (which is the upstream's pattern). + const userMessages = messages.filter((m) => m.role === "user"); + const sysMessages = messages.filter((m) => m.role === "system"); + const lastUser = userMessages[userMessages.length - 1]; + const userText = + typeof lastUser?.content === "string" + ? lastUser.content + : JSON.stringify(lastUser?.content ?? "Hello"); + const sysText = + sysMessages.length > 0 + ? typeof sysMessages[0].content === "string" + ? sysMessages[0].content + : JSON.stringify(sysMessages[0].content) + : null; + const fullText = sysText ? `${sysText}\n\n${userText}` : userText; + + const reqId = randomUUID().replace(/-/g, ""); + + const anthropicBody: Record = { + model: modelId, + max_tokens: maxTokens, + messages: [{ role: "user", content: [{ type: "text", text: fullText }] }], + stream: true, + }; + if (bodyObj.temperature !== undefined) anthropicBody.temperature = bodyObj.temperature; + + const url = new URL(CHAT_URL); + url.searchParams.set("ctoken", ctoken); + + const reqHeaders: Record = { + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + Accept: "text/event-stream", + Origin: "https://zenmux.ai", + Referer: "https://zenmux.ai/platform/chat", + "anthropic-version": "2023-06-01", + "chat-request-id": reqId, + "x-zenmux-accept-processing": "true, true", + "x-zenmux-apikey-source": "subscription", + }; + if (rawCookie) reqHeaders.Cookie = rawCookie; + + let upstream: Response; + try { + upstream = await fetch(url.toString(), { + method: "POST", + headers: reqHeaders, + body: JSON.stringify(anthropicBody), + signal, + }); + } catch (err) { + return makeErrorResult( + 502, + `ZenMux Free fetch failed: ${err instanceof Error ? err.message : "unknown"}`, + body, + CHAT_URL + ); + } + + if (!upstream.ok) { + if (upstream.status === 401 || upstream.status === 403) { + return makeErrorResult(401, "ZenMux Free: cookies expired or invalid", body, CHAT_URL); + } + if (upstream.status === 402) { + return makeErrorResult(402, "ZenMux Free: free-tier quota exhausted", body, CHAT_URL); + } + const errText = await upstream.text().catch(() => ""); + return makeErrorResult(upstream.status, `ZenMux Free error: ${errText}`, body, CHAT_URL); + } + + const cid = `chatcmpl-zmf-${randomUUID().slice(0, 12)}`; + const created = Math.floor(Date.now() / 1000); + + if (!wantStream) { + // Collect SSE text from the Anthropic-format stream + const txt = await collectText(upstream.body); + return { + response: new Response( + JSON.stringify({ + id: cid, + object: "chat.completion", + created, + model: modelId, + choices: [ + { + index: 0, + message: { role: "assistant", content: txt }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 0, completion_tokens: Math.ceil(txt.length / 4), total_tokens: 0 }, + }), + { headers: { "Content-Type": "application/json" } } + ), + url: CHAT_URL, + headers: reqHeaders, + transformedBody: anthropicBody, + }; + } + + // Streaming: translate Anthropic SSE → OpenAI SSE + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const responseStream = new ReadableStream({ + async start(controller) { + const reader = upstream.body?.getReader(); + if (!reader) { + controller.close(); + return; + } + // Send role delta first + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + id: cid, + object: "chat.completion.chunk", + created, + model: modelId, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + })}\n\n` + ) + ); + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + const t = line.trim(); + if (!t.startsWith("data: ")) continue; + const raw = t.slice(6); + if (raw === "[DONE]") { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + continue; + } + try { + const d = JSON.parse(raw) as Record; + const delta = d.delta as Record | undefined; + if (d.type === "content_block_delta" && delta) { + const text = (delta.text as string) || (delta.thinking as string) || ""; + if (text) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + id: cid, + object: "chat.completion.chunk", + created, + model: modelId, + choices: [{ index: 0, delta: { content: text }, finish_reason: null }], + })}\n\n` + ) + ); + } + } else if (d.type === "message_delta" && delta) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + id: cid, + object: "chat.completion.chunk", + created, + model: modelId, + choices: [ + { + index: 0, + delta: {}, + finish_reason: (delta.stop_reason as string) || "stop", + }, + ], + })}\n\n` + ) + ); + } + } catch { + // malformed SSE chunk — skip silently + } + } + } + } catch (err) { + if (!signal?.aborted) controller.error(err); + } finally { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + } + }, + }); + + return { + response: new Response(responseStream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url: CHAT_URL, + headers: reqHeaders, + transformedBody: anthropicBody, + }; + } +} + +/** Collect text from an Anthropic-format SSE stream body. */ +async function collectText(body: ReadableStream | null): Promise { + if (!body) return ""; + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + let txt = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split("\n"); + buf = lines.pop() || ""; + for (const ln of lines) { + const t = ln.trim(); + if (!t.startsWith("data: ")) continue; + try { + const d = JSON.parse(t.slice(6)) as Record; + const delta = d.delta as Record | undefined; + if (d.type === "content_block_delta" && delta) { + txt += (delta.text as string) || (delta.thinking as string) || ""; + } + } catch { + // skip + } + } + } + return txt; +} diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index 9bb280248c..8885d4edba 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "crypto"; import { CORS_HEADERS } from "../utils/cors.ts"; import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; /** @@ -35,22 +34,27 @@ import { signAwsRequest } from "../utils/awsSigV4.ts"; /** * Return a CORS error response from an upstream fetch failure */ +function extractUpstreamErrorMessage(parsed) { + const detail = parsed?.detail; + const candidates = [ + parsed?.err_msg, + parsed?.error?.message, + typeof parsed?.error === "string" ? parsed.error : null, + parsed?.message, + typeof detail === "string" ? detail : detail?.message, + ]; + + const raw = candidates.find(Boolean); + return raw ? String(raw) : null; +} + function upstreamErrorResponse(res, errText) { // Always return JSON so the client can detect 401/credential errors reliably let errorMessage: string; try { const parsed = JSON.parse(errText); - // Extract a human-readable message from various error response shapes. - // Guard against `parsed.error` being an object (e.g. ElevenLabs returns - // { error: { message: "...", status_code: 401 } } or { detail: { ... } }) - const raw = - parsed?.err_msg || - parsed?.error?.message || - (typeof parsed?.error === "string" ? parsed.error : null) || - parsed?.message || - (typeof parsed?.detail === "string" ? parsed.detail : parsed?.detail?.message) || - null; - errorMessage = raw ? String(raw) : errText || `Upstream error (${res.status})`; + errorMessage = + extractUpstreamErrorMessage(parsed) || errText || `Upstream error (${res.status})`; } catch { errorMessage = errText || `Upstream error (${res.status})`; } @@ -791,8 +795,7 @@ function hexToBytes(audioHex) { } async function handleMinimaxSpeech(providerConfig, body, modelId, token) { - const voiceId = - (typeof body.voice === "string" && body.voice) || "English_expressive_narrator"; + const voiceId = (typeof body.voice === "string" && body.voice) || "English_expressive_narrator"; const res = await fetch(providerConfig.baseUrl, { method: "POST", headers: { @@ -835,12 +838,9 @@ async function handleMinimaxSpeech(providerConfig, body, modelId, token) { return upstreamErrorResponse(res, rawText); } - const baseResp = - ((data.base_resp || data.baseResp) as Record | undefined) || {}; + const baseResp = ((data.base_resp || data.baseResp) as Record | undefined) || {}; const statusCode = Number(baseResp.status_code ?? baseResp.statusCode ?? 0); - const statusMessage = String( - baseResp.status_msg || baseResp.statusMsg || data.message || "" - ); + const statusMessage = String(baseResp.status_msg || baseResp.statusMsg || data.message || ""); if (statusCode !== 0) { return errorResponse(502, `MiniMax TTS: ${statusMessage || "upstream error"}`); } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 86f143b27c..b921f1ab77 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -99,7 +99,7 @@ import { normalizeClaudeAdaptiveThinking } from "../services/claudeAdaptiveThink import { normalizeClaudeHaikuConstraints } from "../services/claudeHaikuConstraints.ts"; import { echoModelInObject } from "../services/responseModelEcho.ts"; import { stripGpt5SamplingWhenReasoning } from "../services/gpt5SamplingGuard.ts"; -import { getUnsupportedParams } from "../config/providerRegistry.ts"; +import { getUnsupportedParams, REGISTRY } from "../config/providerRegistry.ts"; import { supportsMaxTokens } from "@/lib/modelCapabilities.ts"; import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts"; import { @@ -279,6 +279,7 @@ import { generateRequestId } from "@/shared/utils/requestId"; import { extractFacts } from "@/lib/memory/extraction"; import { handleToolCallExecution } from "@/lib/skills/interception"; import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; +import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; import { buildClaudeCodeCompatibleRequest, isClaudeCodeCompatibleProvider, @@ -389,6 +390,13 @@ export async function handleChatCore({ // call sites stay byte-identical. const trace = (label: string, extra?: Record) => stageTrace(label, extra, { traceEnabled, startTime, traceId, log }); + const getCurrentConnectionId = () => { + const credentialConnectionId = + typeof credentials?.connectionId === "string" && credentials.connectionId.trim().length > 0 + ? credentials.connectionId.trim() + : null; + return credentialConnectionId || connectionId || null; + }; let tokensCompressed: number | null = null; body = injectSystemPrompt(body); // ── Per-endpoint custom system prompt (port of upstream #2063) ── @@ -397,7 +405,11 @@ export async function handleChatCore({ // only when this function is called outside the normal chat dispatch. { const _s = cachedSettings ?? (await getCachedSettings()); - if (_s.customSystemPromptEnabled === true && typeof _s.customSystemPrompt === "string" && _s.customSystemPrompt) { + if ( + _s.customSystemPromptEnabled === true && + typeof _s.customSystemPrompt === "string" && + _s.customSystemPrompt + ) { body = injectCustomSystemPrompt(body as Record, _s.customSystemPrompt); log?.debug?.("CUSTOMSP", "custom system prompt injected"); } @@ -440,7 +452,7 @@ export async function handleChatCore({ buildFailureUsageRecord({ provider, model, - connectionId, + connectionId: getCurrentConnectionId(), apiKeyInfo, effectiveServiceTier, isCombo, @@ -461,7 +473,8 @@ export async function handleChatCore({ ): void => recordKeyHealthStatusFor(status, creds, log); const persistCodexQuotaState = async (headers: Record | null, status = 0) => { - if (provider !== "codex" || !connectionId || !headers) return; + const currentConnectionId = getCurrentConnectionId(); + if (provider !== "codex" || !currentConnectionId || !headers) return; try { const existingProviderData = @@ -485,10 +498,10 @@ export async function handleChatCore({ // Invalidate the preflight cache for this connection so the next // isModelAvailable check fetches fresh quota data. if (status === 429) { - invalidateCodexQuotaCache(connectionId); + invalidateCodexQuotaCache(currentConnectionId); } - await updateProviderConnection(connectionId, { + await updateProviderConnection(currentConnectionId, { providerSpecificData: built.nextProviderData, }); @@ -783,12 +796,17 @@ export async function handleChatCore({ // sourceFormat="claude" applies the Anthropic Messages spec default (stream=false // when body omits stream), preventing STREAM_EARLY_EOF on /v1/messages when // clients send Accept: */* without an explicit stream flag. + // providerRequiresStreaming: providers with forceStream:true reject stream:false + // upstream (HTTP 400); keep streaming so OmniRoute can convert the stream to JSON + // for the client via handleForcedSSEToJson. (#2081) + const providerRequiresStreaming = REGISTRY[provider]?.forceStream === true; const stream = nativeCodexPassthrough && isCompactResponsesEndpoint(endpointPath) ? false : resolveStreamFlag(body?.stream, acceptHeader, sourceFormat, { userAgent: streamUserAgent, streamDefaultMode: apiKeyInfo?.streamDefaultMode, + providerRequiresStreaming, }); // `settings` is already consolidated once near the top of handleChatCore @@ -1554,6 +1572,9 @@ export async function handleChatCore({ } ccSessionId = resolveClaudeCodeCompatibleSessionId(clientRawRequest?.headers); + const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults( + credentials?.providerSpecificData + ); translatedBody = buildClaudeCodeCompatibleRequest({ sourceBody: body, normalizedBody: normalizedForCc, @@ -1565,6 +1586,7 @@ export async function handleChatCore({ now: new Date(), preserveCacheControl, preserveClaudeMessages: sourceFormat === FORMATS.CLAUDE && isClaudeCodeSemanticPassthrough, + summarizeThinking: ccRequestDefaults.summarizeThinking === true, }); log?.debug?.("FORMAT", "claude-code-compatible bridge enabled"); @@ -1786,8 +1808,8 @@ export async function handleChatCore({ // `_toolNameMap` so kiro-to-openai maps streamed tool-call names back (#1375). if (targetFormat === FORMATS.KIRO) { const kiroTools = - translatedBody?.conversationState?.currentMessage?.userInputMessage - ?.userInputMessageContext?.tools; + translatedBody?.conversationState?.currentMessage?.userInputMessage?.userInputMessageContext + ?.tools; if (kiroTools) { const { tools: sanitizedKiroTools, nameMap: kiroNameMap } = sanitizeKiroTools(kiroTools); translatedBody.conversationState.currentMessage.userInputMessage.userInputMessageContext.tools = @@ -2042,15 +2064,38 @@ export async function handleChatCore({ }); let onPipelineStreamError: streamFailure.PipelineStreamErrorHandler | null = null; + let onClientDisconnectFinalize: + | ((event: { reason: string; duration: number }) => boolean) + | null = null; // Create stream controller for disconnect detection const streamController = createStreamController({ - onDisconnect, + onDisconnect: (event) => { + let finalized = false; + try { + finalized = onClientDisconnectFinalize?.(event) === true; + } catch {} + if (!finalized) { + try { + finalizePendingScope(pendingScope, { + status: 499, + error: `Client disconnected: ${event.reason}`, + errorCode: "client_disconnected", + }); + finalized = true; + } catch {} + } + try { + onDisconnect?.(event); + } catch {} + return finalized; + }, onError: (event) => onPipelineStreamError?.(event), provider, model, connectionId, clientResponseFormat, + clientAbortSignal: clientRawRequest?.signal, }); const dedupRequestBody = { ...translatedBody, model: `${provider}/${model}`, stream }; @@ -2059,17 +2104,6 @@ export async function handleChatCore({ const executeProviderRequest = async (modelToCall = effectiveModel, allowDedup = false) => { const execute = async () => { - const executionCredentials = getExecutionCredentials(); - // Track execution credentials for key health recording (to capture selectedKeyId) - let lastExecCreds = executionCredentials; - const accountSemaphoreMaxConcurrency = - resolveAccountSemaphoreMaxConcurrency(executionCredentials); - const accountSemaphoreKey = resolveAccountSemaphoreKey({ - provider, - model: modelToCall, - connectionId, - credentials: executionCredentials, - }); // Upstream body preparation extracted to chatCore/upstreamBody.ts (#3501 — first internal // sub-slice of executeProviderRequest); produces the body sent upstream (payload rules + // tool-limit truncation + qwen oauth user backfill + prompt_cache_key injection). @@ -2087,90 +2121,105 @@ export async function handleChatCore({ stage: "payload_prepared", }); - trace("pre_semaphore", { - semaphoreKey: accountSemaphoreKey, - max: accountSemaphoreMaxConcurrency, - }); - if (accountSemaphoreKey && accountSemaphoreMaxConcurrency != null) { - updatePendingScope(pendingScope, { - stage: "waiting_account_slot", - }); - } - const acquireAccountSemaphoreRelease = - accountSemaphoreKey && accountSemaphoreMaxConcurrency != null - ? await acquireAccountSemaphore(accountSemaphoreKey, { - maxConcurrency: accountSemaphoreMaxConcurrency, - signal: streamController.signal, - }) - : () => {}; - trace("post_semaphore"); - updatePendingScope(pendingScope, { - stage: "waiting_rate_limit", - }); - + let releaseRawResultAccountSemaphore = () => {}; try { - trace("pre_rate_limit"); - const rawResult = await withRateLimit( - provider, - connectionId, - modelToCall, - async () => { - trace("inside_rate_limit"); - updatePendingScope(pendingScope, { - stage: "rate_limit_slot_acquired", - }); - let attempts = 0; - const isModelScopeForRequest = isModelScope(); - const maxAttempts = isModelScopeForRequest + const rawResult = await (async () => { + let attempts = 0; + const isModelScopeForRequest = isModelScope(); + const maxAttempts = isModelScopeForRequest + ? 3 + : provider === "qwen" ? 3 - : provider === "qwen" + : provider === "codex" ? 3 - : provider === "codex" - ? 3 - : 1; + : 1; - // ── Codex 429 account-rotation state ───────────────────────────────── - // Track excluded connection IDs for codex failover across attempts. - const codexExcludedIds: string[] = []; - // Derive session affinity key once for codex failover (used to clear affinity on 429). - const codexSessionAffinityKey = - provider === "codex" - ? (extractSessionAffinityKey(body, clientRawRequest?.headers) ?? null) - : null; + // ── Codex 429 account-rotation state ───────────────────────────────── + // Track excluded connection IDs for codex failover across attempts. + const codexExcludedIds: string[] = []; + // Derive session affinity key once for codex failover (used to clear affinity on 429). + const codexSessionAffinityKey = + provider === "codex" + ? (extractSessionAffinityKey(body, clientRawRequest?.headers) ?? null) + : null; - while (attempts < maxAttempts) { - trace("pre_executor", { attempt: attempts }); + while (attempts < maxAttempts) { + trace("pre_executor", { attempt: attempts }); + updatePendingScope(pendingScope, { + stage: "sending_to_provider", + }); + const execCreds = getExecutionCredentials(); + const attemptConnectionId = execCreds?.connectionId || connectionId; + const accountSemaphoreMaxConcurrency = resolveAccountSemaphoreMaxConcurrency(execCreds); + const accountSemaphoreKey = resolveAccountSemaphoreKey({ + provider, + model: modelToCall, + connectionId: attemptConnectionId, + credentials: execCreds, + }); + + trace("pre_semaphore", { + semaphoreKey: accountSemaphoreKey, + max: accountSemaphoreMaxConcurrency, + }); + if (accountSemaphoreKey && accountSemaphoreMaxConcurrency != null) { updatePendingScope(pendingScope, { - stage: "sending_to_provider", + stage: "waiting_account_slot", }); - const execCreds = getExecutionCredentials(); - const rawExecutorResult = await executeWithUpstreamStartTimeout({ - executor, + } + const releaseAccountSemaphore = + accountSemaphoreKey && accountSemaphoreMaxConcurrency != null + ? await acquireAccountSemaphore(accountSemaphoreKey, { + maxConcurrency: accountSemaphoreMaxConcurrency, + signal: streamController.signal, + }) + : () => {}; + trace("post_semaphore"); + updatePendingScope(pendingScope, { + stage: "waiting_rate_limit", + }); + + try { + trace("pre_rate_limit", { connectionId: attemptConnectionId }); + const rawExecutorResult = await withRateLimit( provider, - model: modelToCall, - signal: streamController.signal, - log, - execute: (signal) => - runWithCapture(providerRequestCapture, () => - executor.execute({ - model: modelToCall, - body: bodyToSend, - stream: upstreamStream, - credentials: execCreds, - signal, - log, - extendedContext, - upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall), - clientHeaders: buildExecutorClientHeaders( - clientRawRequest?.headers, - userAgent + attemptConnectionId, + modelToCall, + async () => { + trace("inside_rate_limit", { connectionId: attemptConnectionId }); + updatePendingScope(pendingScope, { + stage: "rate_limit_slot_acquired", + }); + return executeWithUpstreamStartTimeout({ + executor, + provider, + model: modelToCall, + signal: streamController.signal, + log, + execute: (signal) => + runWithCapture(providerRequestCapture, () => + executor.execute({ + model: modelToCall, + body: bodyToSend, + stream: upstreamStream, + credentials: execCreds, + signal, + log, + extendedContext, + upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall), + clientHeaders: buildExecutorClientHeaders( + clientRawRequest?.headers, + userAgent + ), + onCredentialsRefreshed, + skipUpstreamRetry, + contextEditing: { enabled: contextEditingEnabled }, + }) ), - onCredentialsRefreshed, - skipUpstreamRetry, - contextEditing: { enabled: contextEditingEnabled }, - }) - ), - }); + }); + }, + streamController.signal + ); const res = normalizeExecutorResult(rawExecutorResult); trace("post_executor", { status: res?.response?.status }); @@ -2200,6 +2249,7 @@ export async function handleChatCore({ if (bodyPeek.toLowerCase().includes("exceeded your current quota")) { const delay = 1500 * (attempts + 1); log?.warn?.("QWEN_RETRY", `Quota 429 hit. Retrying in ${delay}ms...`); + releaseAccountSemaphore(); await new Promise((r) => setTimeout(r, delay)); attempts++; continue; @@ -2219,6 +2269,7 @@ export async function handleChatCore({ "MODELSCOPE_RETRY", `429 ${decision.kind}; retrying in ${delay}ms (model remaining: ${decision.snapshot.modelRemaining ?? "unknown"})` ); + releaseAccountSemaphore(); await new Promise((r) => setTimeout(r, delay)); attempts++; continue; @@ -2232,7 +2283,8 @@ export async function handleChatCore({ res.response.status === 429 && attempts < maxAttempts - 1 ) { - const failedConnectionId = credentials?.connectionId || connectionId; + const failedConnectionId = + execCreds?.connectionId || credentials?.connectionId || connectionId; const normalizedHeaders = normalizeHeaders(res.response.headers); const retryAfterHeader = normalizedHeaders["retry-after"] ?? null; const retryAfterMs = retryAfterHeader @@ -2279,7 +2331,18 @@ export async function handleChatCore({ if (!nextCreds || nextCreds.allRateLimited) { log?.warn?.("CODEX_FAILOVER", "No more codex accounts available — returning 429"); - return res; + if (stream) { + releaseAccountSemaphore(); + return { + ...res, + _executionCredentials: execCreds, + }; + } + return { + ...res, + _accountSemaphoreRelease: releaseAccountSemaphore, + _executionCredentials: execCreds, + }; } const newConnectionId = nextCreds.connectionId; @@ -2303,6 +2366,7 @@ export async function handleChatCore({ // Update credentials in-place so getExecutionCredentials() picks up the new account Object.assign(credentials, nextCreds); + releaseAccountSemaphore(); attempts++; continue; } @@ -2311,7 +2375,7 @@ export async function handleChatCore({ if (stream) { const originalBody = res.response.body; if (!originalBody) { - acquireAccountSemaphoreRelease(); + releaseAccountSemaphore(); return res; } @@ -2403,7 +2467,7 @@ export async function handleChatCore({ originalBody as ReadableStream, () => runUpstreamStream(bodyToSend), { - finalize: acquireAccountSemaphoreRelease, + finalize: releaseAccountSemaphore, onRetry: (attempt, err) => log?.warn?.( "STREAM_RECOVERY", @@ -2422,7 +2486,7 @@ export async function handleChatCore({ } else { clientBody = wrapReadableStreamWithFinalize( originalBody, - acquireAccountSemaphoreRelease + releaseAccountSemaphore ); } @@ -2441,11 +2505,14 @@ export async function handleChatCore({ return { ...res, _executionCredentials: execCreds, + _accountSemaphoreRelease: releaseAccountSemaphore, }; + } catch (error) { + releaseAccountSemaphore(); + throw error; } - }, - streamController.signal - ); + } + })(); if (stream) { return rawResult; @@ -2461,6 +2528,10 @@ export async function handleChatCore({ ) { recordKeyHealthStatus(status, rawResult._executionCredentials); } + releaseRawResultAccountSemaphore = + typeof rawResult._accountSemaphoreRelease === "function" + ? rawResult._accountSemaphoreRelease + : () => {}; const statusText = rawResult.response.statusText; const headersObj = normalizeHeaders(rawResult.response.headers); @@ -2472,7 +2543,8 @@ export async function handleChatCore({ contentType, upstreamStream ); - acquireAccountSemaphoreRelease(); + releaseRawResultAccountSemaphore(); + releaseRawResultAccountSemaphore = () => {}; return { ...rawResult, @@ -2490,7 +2562,7 @@ export async function handleChatCore({ }, }; } catch (error) { - acquireAccountSemaphoreRelease(); + releaseRawResultAccountSemaphore(); throw error; } }; @@ -2587,6 +2659,7 @@ export async function handleChatCore({ providerUrl = result.url; providerHeaders = result.headers; finalBody = providerRequestCapture.body(result.transformedBody); + const responseConnectionId = getCurrentConnectionId(); effectiveServiceTier = resolveEffectiveServiceTier(finalBody); claudePromptCacheLogMeta = buildClaudePromptCacheLogMeta( targetFormat, @@ -2605,7 +2678,7 @@ export async function handleChatCore({ // Update rate limiter from response headers (learn limits dynamically) updateFromHeaders( provider, - connectionId, + responseConnectionId, providerResponse.headers, providerResponse.status, model @@ -2615,7 +2688,7 @@ export async function handleChatCore({ try { const { storeRateLimitHeaders } = await import("@/lib/quota/saturationSignals"); storeRateLimitHeaders( - connectionId, + responseConnectionId, provider, providerResponse.headers as Record ); @@ -2663,8 +2736,15 @@ export async function handleChatCore({ ? "Request aborted" : formatProviderError(error, provider, model, failureStatus); const upstreamErrorCode = getUpstreamErrorIdentifier(error); + // Tag our own deadline timeouts (fetch-start TimeoutError / body BodyTimeoutError, + // both surfaced as a 504) as "upstream_timeout" so the cooldown layer can tell a + // slow-but-not-failed request apart from a real provider 5xx. (Antigravity already + // tags its pre-response timeout via the code below.) + const isOwnDeadlineTimeout = + failureStatus === HTTP_STATUS.GATEWAY_TIMEOUT && + (error.name === "TimeoutError" || error.name === "BodyTimeoutError"); const upstreamErrorType = - upstreamErrorCode === ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE + upstreamErrorCode === ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE || isOwnDeadlineTimeout ? "upstream_timeout" : failureStatus === 401 ? "authentication_error" @@ -2929,10 +3009,11 @@ export async function handleChatCore({ `${decision.kind} (model remaining: ${decision.snapshot.modelRemaining ?? "unknown"}, total remaining: ${decision.snapshot.totalRemaining ?? "unknown"})` ); } - if (connectionId && errorType) { + const errorConnectionId = getCurrentConnectionId(); + if (errorConnectionId && errorType) { try { if (errorType === PROVIDER_ERROR_TYPES.FORBIDDEN) { - await updateProviderConnection(connectionId, { + await updateProviderConnection(errorConnectionId, { isActive: false, testStatus: "banned", lastErrorType: errorType, @@ -2940,28 +3021,28 @@ export async function handleChatCore({ errorCode: statusCode, }); console.warn( - `[provider] Node ${connectionId} banned (${statusCode}) — disabling permanently` + `[provider] Node ${errorConnectionId} banned (${statusCode}) — disabling permanently` ); } else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) { // Plan A: if connection has extra API keys, don't disable — only the failing key is affected. // Single-key connections still get disabled as before. if ( connectionHasExtraKeys( - connectionId, + errorConnectionId, (credentials?.providerSpecificData as Record | undefined) ?.extraApiKeys as string[] | undefined ) ) { - await updateProviderConnection(connectionId, { + await updateProviderConnection(errorConnectionId, { lastErrorType: errorType, lastError: message, errorCode: statusCode, }); console.warn( - `[provider] Node ${connectionId} account deactivated (${statusCode}) — has extra keys, keeping connection active` + `[provider] Node ${errorConnectionId} account deactivated (${statusCode}) — has extra keys, keeping connection active` ); } else { - await updateProviderConnection(connectionId, { + await updateProviderConnection(errorConnectionId, { isActive: false, testStatus: "deactivated", lastErrorType: errorType, @@ -2969,7 +3050,7 @@ export async function handleChatCore({ errorCode: statusCode, }); console.warn( - `[provider] Node ${connectionId} account deactivated (${statusCode}) — disabling permanently` + `[provider] Node ${errorConnectionId} account deactivated (${statusCode}) — disabling permanently` ); } } else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) { @@ -2978,75 +3059,64 @@ export async function handleChatCore({ const accountSemaphoreKey = resolveAccountSemaphoreKey({ provider, model: currentModel, - connectionId, + connectionId: errorConnectionId, credentials, }); if (accountSemaphoreKey) { markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs); } - if (isModelScope() && connectionId) { - lockModel(provider, connectionId, model, "quota_exhausted", quotaCooldownMs); + if (isModelScope() && errorConnectionId) { + lockModel(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs); console.warn( - `[provider] Node ${connectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)` + `[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)` ); } else if ( lockModelIfPerModelQuota( provider, - connectionId, + errorConnectionId, model, "quota_exhausted", quotaCooldownMs ) ) { console.warn( - `[provider] Node ${connectionId} model-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)` + `[provider] Node ${errorConnectionId} model-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)` ); } else { - await updateProviderConnection(connectionId, { + await updateProviderConnection(errorConnectionId, { testStatus: "credits_exhausted", lastErrorType: errorType, lastError: message, errorCode: statusCode, }); - console.warn(`[provider] Node ${connectionId} exhausted quota (${statusCode})`); + console.warn(`[provider] Node ${errorConnectionId} exhausted quota (${statusCode})`); } - } else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) { - await updateProviderConnection(connectionId, { - isActive: false, - testStatus: "expired", - lastErrorType: errorType, - lastError: message, - errorCode: statusCode, - }); - console.warn( - `[provider] Node ${connectionId} account deactivated (${statusCode}) — marked expired` - ); } else if (errorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED) { // Normal 401 (token/session auth issue): keep account active for refresh/re-auth. - await updateProviderConnection(connectionId, { + await updateProviderConnection(errorConnectionId, { lastErrorType: errorType, lastError: message, errorCode: statusCode, }); } else if (errorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN) { // OAuth 401 with invalid credentials - token refresh can recover - await updateProviderConnection(connectionId, { + await updateProviderConnection(errorConnectionId, { lastErrorType: errorType, lastError: message, errorCode: statusCode, }); console.warn( - `[provider] Node ${connectionId} OAuth token invalid (${statusCode}) — token refresh available` + `[provider] Node ${errorConnectionId} OAuth token invalid (${statusCode}) — token refresh available` ); } else if (errorType === PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR) { // Cloud Code 403 with stale project: not a ban, keep account active. - await updateProviderConnection(connectionId, { + await updateProviderConnection(errorConnectionId, { lastErrorType: errorType, lastError: message, errorCode: statusCode, }); console.warn( - `[provider] Node ${connectionId} project routing error (${statusCode}) — not banning` + `[provider] Node ${errorConnectionId} project routing error (${statusCode}) — not banning` ); } } catch { @@ -3054,9 +3124,12 @@ export async function handleChatCore({ } } - appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch( - () => {} - ); + appendRequestLog({ + model, + provider, + connectionId: errorConnectionId, + status: `FAILED ${statusCode}`, + }).catch(() => {}); const errMsg = formatProviderError(new Error(message), provider, model, statusCode); console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); @@ -3077,9 +3150,9 @@ export async function handleChatCore({ ); // Update rate limiter from error response headers - updateFromHeaders(provider, connectionId, providerResponse.headers, statusCode, model); - if (connectionId && upstreamErrorBody !== null && upstreamErrorBody !== undefined) { - updateFromResponseBody(provider, connectionId, upstreamErrorBody, statusCode, model); + updateFromHeaders(provider, errorConnectionId, providerResponse.headers, statusCode, model); + if (errorConnectionId && upstreamErrorBody !== null && upstreamErrorBody !== undefined) { + updateFromResponseBody(provider, errorConnectionId, upstreamErrorBody, statusCode, model); } // ── T5: Intra-family model fallback ────────────────────────────────────── @@ -3432,9 +3505,10 @@ export async function handleChatCore({ if (onRequestSuccess) { await onRequestSuccess(); } + const successConnectionId = getCurrentConnectionId(); await maybeSyncClaudeExtraUsageState({ provider, - connectionId, + connectionId: successConnectionId, providerSpecificData: credentials?.providerSpecificData, log, }); @@ -3455,16 +3529,20 @@ export async function handleChatCore({ skillRequestId, log, }); - appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch( - () => {} - ); + appendRequestLog({ + model, + provider, + connectionId: successConnectionId, + tokens: usage, + status: "200 OK", + }).catch(() => {}); // Save structured call log with full payloads const cacheUsageLogMeta = buildCacheUsageLogMeta(usage); recordNonStreamingUsageStats(usage, { traceEnabled, provider, - connectionId, + connectionId: successConnectionId, model, startTime, apiKeyInfo, @@ -3842,6 +3920,7 @@ export async function handleChatCore({ (finalBody as Record | null | undefined) ?? null ); + let streamCompletionRecorded = false; let streamFailureCompletionRecorded = false; // Callback to save call log when stream completes (include responseBody when provided by stream) @@ -3856,16 +3935,19 @@ export async function handleChatCore({ ttft, }) => { const normalizedStreamStatus = streamStatus || 200; + if (streamCompletionRecorded) return; + streamCompletionRecorded = true; if (normalizedStreamStatus !== 200) { if (streamFailureCompletionRecorded) return; streamFailureCompletionRecorded = true; } const cacheUsageLogMeta = buildCacheUsageLogMeta(streamUsage); + const streamConnectionId = getCurrentConnectionId(); if (normalizedStreamStatus === 200) { void maybeSyncClaudeExtraUsageState({ provider, - connectionId, + connectionId: streamConnectionId, providerSpecificData: credentials?.providerSpecificData, log, }); @@ -3892,7 +3974,7 @@ export async function handleChatCore({ pendingRequestId, model, provider, - connectionId: connectionId || credentials?.connectionId || null, + connectionId: streamConnectionId, providerResponse: providerPayload ?? streamResponseBody ?? undefined, clientResponse: clientPayload ?? streamResponseBody ?? undefined, status: normalizedStreamStatus, @@ -3911,7 +3993,7 @@ export async function handleChatCore({ startTime, ttft, streamErrorCode, - connectionId, + connectionId: streamConnectionId, apiKeyInfo, effectiveServiceTier, isCombo, @@ -3994,12 +4076,20 @@ export async function handleChatCore({ const streamFailureFinalizers = streamFailure.createStreamFailureFinalizers({ isFailureCompletionRecorded: () => streamFailureCompletionRecorded, + isStreamCompletionRecorded: () => streamCompletionRecorded, onStreamComplete, persistFailureUsage, onStreamFailure, }); const handleStreamFailure = streamFailureFinalizers.handleStreamFailure; onPipelineStreamError = streamFailureFinalizers.onPipelineStreamError; + onClientDisconnectFinalize = (event) => + handleStreamFailure({ + status: 499, + message: `Client disconnected: ${event.reason}`, + code: "client_disconnected", + type: "client_disconnected", + }); // For providers using Responses API format, translate stream back to openai (Chat Completions) format // UNLESS client is Droid CLI which expects openai-responses format back diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts index 7414b951da..86947eecbd 100644 --- a/open-sse/handlers/chatCore/attemptLogging.ts +++ b/open-sse/handlers/chatCore/attemptLogging.ts @@ -35,10 +35,7 @@ export type PersistAttemptLogsContext = { model: string | null | undefined; skillRequestId: string; detailedLoggingEnabled: boolean; - reqLogger: - | { getPipelinePayloads?: () => Record | undefined } - | null - | undefined; + reqLogger: { getPipelinePayloads?: () => Record | undefined } | null | undefined; pendingRequestId: unknown; clientRawRequest: { endpoint?: string } | null | undefined; requestedModel: unknown; @@ -55,6 +52,26 @@ export type PersistAttemptLogsContext = { noLogEnabled: unknown; }; +function toConnectionId(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function buildAccountRotationMeta( + provider: string | null | undefined, + initialConnectionId: string | null, + finalConnectionId: string | null +) { + if (provider !== "codex" || !initialConnectionId || !finalConnectionId) return null; + if (initialConnectionId === finalConnectionId) return null; + + return { + codexAccountRotation: { + initialConnectionId, + finalConnectionId, + }, + }; +} + export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAttemptLogsContext) { const { status, @@ -90,20 +107,27 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt apiKeyInfo, noLogEnabled, } = ctx; + const initialConnectionId = toConnectionId(connectionId); + const finalConnectionId = toConnectionId(credentials?.connectionId) || initialConnectionId; + const accountRotationMeta = buildAccountRotationMeta( + provider, + initialConnectionId, + finalConnectionId + ); const providerWarnings = extractProviderWarnings(providerResponse, clientResponse, responseBody); if (providerWarnings.length > 0) { logAuditEvent({ action: "provider.warning", actor: "system", - target: [provider, connectionId].filter(Boolean).join(":") || provider || model, + target: [provider, finalConnectionId].filter(Boolean).join(":") || provider || model, resourceType: "provider_warning", status: "warning", requestId: skillRequestId, details: { provider, model, - connectionId, + connectionId: finalConnectionId, httpStatus: status, warnings: providerWarnings, }, @@ -142,16 +166,18 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt model, requestedModel, provider, - connectionId: connectionId || credentials?.connectionId || undefined, + connectionId: finalConnectionId || undefined, duration: Date.now() - startTime, tokens: tokens || {}, requestBody: cloneBoundedChatLogPayload( attachLogMeta(truncateForLog(body as Record), { + ...accountRotationMeta, claudePromptCache: claudeCacheMeta, }) ), responseBody: cloneBoundedChatLogPayload( attachLogMeta(truncateForLog(responseBody as Record), { + ...accountRotationMeta, claudePromptCache: claudeCacheMeta ? { applied: claudeCacheMeta.applied, diff --git a/open-sse/handlers/chatCore/cooldownClassification.ts b/open-sse/handlers/chatCore/cooldownClassification.ts new file mode 100644 index 0000000000..a334e2ff4a --- /dev/null +++ b/open-sse/handlers/chatCore/cooldownClassification.ts @@ -0,0 +1,26 @@ +import { HTTP_STATUS } from "../../config/constants.ts"; + +/** + * Whether a failed single-model attempt is a *self-inflicted* upstream timeout — i.e. + * OmniRoute's own deadline (fetch-start `TimeoutError`, body `BodyTimeoutError`, or the + * combo-per-model timeout) fired while the upstream was still processing the request, + * surfaced as a 504 tagged `errorType: "upstream_timeout"`. + * + * Such a timeout is NOT a provider rejection — the connection is healthy, we just gave + * up waiting — so the caller must skip the connection cooldown for it. Cooling the + * connection down on our own timeout penalises a healthy account and, when a provider + * has a single connection, blocks every subsequent request behind a self-inflicted + * cooldown. Antigravity keeps its own pre-response-timeout cooldown policy and is + * therefore excluded here. + */ +export function isSelfInflictedUpstreamTimeout( + status: number, + errorType: string | undefined | null, + provider: string +): boolean { + return ( + status === HTTP_STATUS.GATEWAY_TIMEOUT && + errorType === "upstream_timeout" && + provider !== "antigravity" + ); +} diff --git a/open-sse/handlers/chatCore/jsonBodyToSse.ts b/open-sse/handlers/chatCore/jsonBodyToSse.ts index fa4db8867d..66c9688807 100644 --- a/open-sse/handlers/chatCore/jsonBodyToSse.ts +++ b/open-sse/handlers/chatCore/jsonBodyToSse.ts @@ -28,6 +28,95 @@ const DEFAULT_DEPS: JsonBodyToSseDeps = { synthesizeOpenAiSseFromJson: defaultSynthesize, }; +function prependBufferedChunks( + chunks: Uint8Array[], + reader: ReadableStreamDefaultReader +): ReadableStream { + let index = 0; + return new ReadableStream({ + async pull(controller) { + if (index < chunks.length) { + controller.enqueue(chunks[index++]); + return; + } + try { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + } catch (error) { + controller.error(error); + } + }, + async cancel(reason) { + try { + await reader.cancel(reason); + } catch {} + }, + }); +} + +function classifyBodyPrefix(text: string): "sse" | "non-sse" | "unknown" { + const trimmed = text.replace(/^\uFEFF/, "").trimStart(); + if (!trimmed) return "unknown"; + if (trimmed.startsWith(":")) return "sse"; + if (/^(?:data|event|id|retry)\s*:/i.test(trimmed)) return "sse"; + + const lower = trimmed.toLowerCase(); + for (const field of ["data", "event", "id", "retry"]) { + if (field.startsWith(lower)) return "unknown"; + if (lower.startsWith(field)) { + const rest = lower.slice(field.length); + if (/^\s*$/.test(rest)) return "unknown"; + if (/^\s*:/.test(rest)) return "sse"; + } + } + + return "non-sse"; +} + +async function sniffJsonBodyForSse( + providerResponse: Response, + ctx: { log?: LoggerLike; provider: string | null | undefined; model: string | null | undefined }, + deps: JsonBodyToSseDeps +): Promise<{ sseResponse?: Response; jsonBody: Response }> { + const reader = providerResponse.body!.getReader(); + const bufferedChunks: Uint8Array[] = []; + const decoder = new TextDecoder(); + let sniffed = ""; + let sniffedBytes = 0; + const maxSniffBytes = 4096; + while (sniffedBytes < maxSniffBytes) { + const chunk = await deps.withBodyTimeout>(reader.read()); + if (chunk.done || !chunk.value) break; + bufferedChunks.push(chunk.value); + sniffedBytes += chunk.value.byteLength; + sniffed += decoder.decode(chunk.value, { stream: true }); + + if (classifyBodyPrefix(sniffed) === "sse") { + const rebuiltHeaders = new Headers(providerResponse.headers); + rebuiltHeaders.delete("content-length"); + rebuiltHeaders.set("content-type", "text/event-stream"); + ctx.log?.debug?.( + "STREAM", + `Upstream returned SSE bytes with application/json content-type — preserving streaming body (${ctx.provider}/${ctx.model})` + ); + return { + sseResponse: new Response(prependBufferedChunks(bufferedChunks, reader), { + status: providerResponse.status, + statusText: providerResponse.statusText, + headers: rebuiltHeaders, + }), + jsonBody: new Response(null), + }; + } + } + + return { jsonBody: new Response(prependBufferedChunks(bufferedChunks, reader)) }; +} + export async function maybeConvertJsonBodyToSse( providerResponse: Response, ctx: { log?: LoggerLike; provider: string | null | undefined; model: string | null | undefined }, @@ -42,7 +131,11 @@ export async function maybeConvertJsonBodyToSse( if (!isNonSseJsonBody) { return providerResponse; } - const jsonText = await deps.withBodyTimeout(providerResponse.text()); + + const { sseResponse, jsonBody } = await sniffJsonBodyForSse(providerResponse, ctx, deps); + if (sseResponse) return sseResponse; + + const jsonText = await deps.withBodyTimeout(jsonBody.text()); const synthesizedSse = deps.synthesizeOpenAiSseFromJson(jsonText); const rebuiltHeaders = new Headers(providerResponse.headers); rebuiltHeaders.delete("content-length"); diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index f852aedac0..10cd24b619 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -1,3 +1,8 @@ +import { + copyOpenAICompatibleReasoningFields, + getReadableReasoningValue, +} from "../utils/reasoningFields.ts"; + /** * Response Sanitizer — Normalizes LLM responses to strict OpenAI SDK format. * @@ -29,12 +34,6 @@ type JsonRecord = Record; export const OMIT_STREAMING_CHUNK_MARKER = "__omniroute_omit_streaming_chunk"; -const DEEPSEEK_V4_SANITIZER_MODEL_PATTERN = /deepseek[-/]v4/i; - -function isDeepSeekV4Model(model: unknown): boolean { - return typeof model === "string" && DEEPSEEK_V4_SANITIZER_MODEL_PATTERN.test(model); -} - function toRecord(value: unknown): JsonRecord | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; return value as JsonRecord; @@ -48,6 +47,13 @@ function toNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function deleteOpenAICompatibleReasoningFields(record: JsonRecord): void { + delete record.reasoning_content; + delete record.reasoning; + delete record.reasoning_text; + delete record.reasoning_details; +} + function stripZeroWidthText(value: string): string { return value.replace(/[\u200B-\u200D\uFEFF]/g, ""); } @@ -183,23 +189,6 @@ function containsTextualToolCallContent(content: unknown): boolean { ); } -function hasVisibleMessageContent(content: unknown): boolean { - if (typeof content === "string") { - return content.trim().length > 0; - } - - if (!Array.isArray(content)) return false; - - return content.some((contentPart) => { - const part = toRecord(contentPart); - if (!part) return false; - if (typeof part.text === "string" && part.text.trim().length > 0) return true; - if (typeof part.content === "string" && part.content.trim().length > 0) return true; - const partType = toString(part.type); - return Boolean(partType && partType !== "thinking" && partType !== "reasoning"); - }); -} - const REASONING_TAG_NAMES = ["think", "thinking", "thought", "internal_thought"]; const REASONING_TAG_PATTERN = REASONING_TAG_NAMES.join("|"); // Matches complete /// blocks. @@ -207,6 +196,9 @@ const THINK_TAG_REGEX = new RegExp( `<(${REASONING_TAG_PATTERN})\\b[^>]*>([\\s\\S]*?)<\\/\\1>`, "gi" ); +const REASONING_CLOSE_TAG_REGEX = new RegExp(``, "i"); +const REASONING_TAG_FRAGMENT_REGEX = new RegExp(`]*>`, "gi"); +const CONTENT_OPEN_TAG_REGEX = /]*>/i; // Matches an unclosed reasoning tag at the end of a message. Some providers can // emit malformed/open reasoning wrappers (for example " blocks from text content and return separated parts. * @returns {{ content: string, thinking: string | null }} @@ -246,6 +268,13 @@ export function extractThinkingFromContent(text: string): { return ""; }); + if (!hasThinkTags) { + const closingOnly = splitClosingOnlyReasoningPrefix(cleaned); + if (closingOnly) { + return closingOnly; + } + } + const unclosedMatch = cleaned.match(UNCLOSED_REASONING_TAG_REGEX); if (unclosedMatch?.index !== undefined) { hasThinkTags = true; @@ -259,6 +288,8 @@ export function extractThinkingFromContent(text: string): { return { content: text, thinking: null }; } + cleaned = movePrefixBeforeContentTagToThinking(cleaned, thinkingParts); + return { content: cleaned.trim(), thinking: thinkingParts.length > 0 ? thinkingParts.join("\n\n") : null, @@ -287,7 +318,6 @@ export function sanitizeOpenAIResponse( ): unknown { const bodyRecord = toRecord(body); if (!bodyRecord) return body; - const isDeepSeekV4 = isDeepSeekV4Model(bodyRecord.model); const stripReasoning = options.stripReasoning === true; // Build sanitized response with only allowed top-level fields @@ -302,7 +332,7 @@ export function sanitizeOpenAIResponse( // Sanitize choices if (Array.isArray(bodyRecord.choices)) { sanitized.choices = bodyRecord.choices.map((choice, idx) => { - const sanitizedChoice = sanitizeChoice(choice, idx, isDeepSeekV4); + const sanitizedChoice = sanitizeChoice(choice, idx); const message = toRecord(sanitizedChoice.message); if ( message && @@ -312,8 +342,8 @@ export function sanitizeOpenAIResponse( ) { sanitizedChoice.finish_reason = "tool_calls"; } - if (stripReasoning && message && "reasoning_content" in message) { - delete message.reasoning_content; + if (stripReasoning && message) { + deleteOpenAICompatibleReasoningFields(message); } return sanitizedChoice; }); @@ -361,6 +391,23 @@ export function sanitizeResponsesApiResponse(body: unknown): unknown { }; const output = sanitizeResponsesOutput(responseRoot.output); + + // Some upstreams return a shorthand Responses body that carries the answer only + // in `output_text` with an empty/absent `output[]`. Synthesize a message item so + // the sanitized response still has usable structured output — otherwise the text + // is dropped and the response is later flagged malformed (#4942 regression). + if ( + output.length === 0 && + typeof responseRoot.output_text === "string" && + responseRoot.output_text.trim().length > 0 + ) { + output.push({ + id: "msg_0", + type: "message", + role: "assistant", + content: [{ type: "output_text", text: responseRoot.output_text.trim() }], + }); + } sanitized.output = output; const outputText = extractResponsesOutputText(output); @@ -378,7 +425,7 @@ export function sanitizeResponsesApiResponse(body: unknown): unknown { /** * Sanitize a single choice object. */ -function sanitizeChoice(choice: unknown, defaultIndex: number, isDeepSeekV4 = false): JsonRecord { +function sanitizeChoice(choice: unknown, defaultIndex: number): JsonRecord { const choiceRecord = toRecord(choice); const sanitized: JsonRecord = { index: defaultIndex, @@ -395,7 +442,7 @@ function sanitizeChoice(choice: unknown, defaultIndex: number, isDeepSeekV4 = fa // Sanitize message (non-streaming) or delta (streaming) if (choiceRecord?.message !== undefined) { - sanitized.message = sanitizeMessage(choiceRecord.message, isDeepSeekV4); + sanitized.message = sanitizeMessage(choiceRecord.message); } if (choiceRecord?.delta !== undefined) { sanitized.delta = sanitizeMessage(choiceRecord.delta); @@ -412,7 +459,7 @@ function sanitizeChoice(choice: unknown, defaultIndex: number, isDeepSeekV4 = fa /** * Sanitize a message object, extracting tags if present. */ -function sanitizeMessage(msg: unknown, isDeepSeekV4 = false): unknown { +function sanitizeMessage(msg: unknown): unknown { const msgRecord = toRecord(msg); if (!msgRecord) return msg; @@ -429,69 +476,16 @@ function sanitizeMessage(msg: unknown, isDeepSeekV4 = false): unknown { ); sanitized.content = collapseExcessiveNewlines(content); - // Set reasoning_content from tags (if not already set) - if (thinking && !msgRecord.reasoning_content) { + // Set reasoning_content from prompt-format tags only when the provider did + // not also return a native OpenAI-compatible reasoning field. + if (thinking && !getReadableReasoningValue(msgRecord)) { sanitized.reasoning_content = thinking; } } else if (msgRecord.content !== undefined) { sanitized.content = msgRecord.content; } - // Preserve existing reasoning_content (from providers that natively support it) - if (msgRecord.reasoning_content && !sanitized.reasoning_content) { - sanitized.reasoning_content = msgRecord.reasoning_content; - } - - // Handle 'reasoning' field alias (some providers use this instead of reasoning_content) - if ( - msgRecord.reasoning && - typeof msgRecord.reasoning === "string" && - !sanitized.reasoning_content - ) { - sanitized.reasoning_content = msgRecord.reasoning; - } - - // Handle reasoning_details[] array (StepFun/OpenRouter format) - // Structure: [{ type: "reasoning.text", text: "...", format: "unknown", index: 0 }] - if (Array.isArray(msgRecord.reasoning_details) && !sanitized.reasoning_content) { - const reasoningParts: string[] = []; - for (const detail of msgRecord.reasoning_details) { - const detailObj = detail && typeof detail === "object" ? (detail as JsonRecord) : null; - if (!detailObj) continue; - const detailType = typeof detailObj.type === "string" ? detailObj.type : ""; - const detailText = - typeof detailObj.text === "string" - ? detailObj.text - : typeof detailObj.content === "string" - ? detailObj.content - : ""; - if ( - detailText && - (detailType === "reasoning" || - detailType === "reasoning.text" || - detailType === "thinking" || - detailType === "") - ) { - reasoningParts.push(detailText); - } - } - if (reasoningParts.length > 0) { - sanitized.reasoning_content = reasoningParts.join(""); - } - } - - // Non-streaming responses should not expose both visible content and reasoning_content. - // Some clients drop the visible assistant text or render duplicated panels when both fields - // are present in the final payload. Keep reasoning_content only for reasoning-only messages. - if ( - sanitized.reasoning_content !== undefined && - hasVisibleMessageContent(sanitized.content) && - !msgRecord.tool_calls && - !msgRecord.function_call && - !isDeepSeekV4 - ) { - delete sanitized.reasoning_content; - } + copyOpenAICompatibleReasoningFields(msgRecord, sanitized); const textualToolCall = parseTextualToolCallContent(sanitized.content); if (textualToolCall && !msgRecord.tool_calls) { @@ -1055,32 +1049,7 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown { ? collapseExcessiveNewlines(deltaRecord.content) : deltaRecord.content; } - if (deltaRecord.reasoning_content !== undefined) { - delta.reasoning_content = deltaRecord.reasoning_content; - } - if (deltaRecord.reasoning_text !== undefined) { - delta.reasoning_text = deltaRecord.reasoning_text; - } else if (typeof deltaRecord.reasoning === "string" && deltaRecord.reasoning) { - // Alias: some providers use 'reasoning' instead of 'reasoning_content' - delta.reasoning_content = deltaRecord.reasoning; - } else if (Array.isArray(deltaRecord.reasoning_details)) { - // StepFun/OpenRouter: reasoning_details[{type:"reasoning.text", text:"..."}] - const parts: string[] = []; - for (const detail of deltaRecord.reasoning_details) { - const d = detail && typeof detail === "object" ? (detail as JsonRecord) : null; - if (!d) continue; - const text = - typeof d.text === "string" - ? d.text - : typeof d.content === "string" - ? d.content - : ""; - if (text) parts.push(text); - } - if (parts.length > 0) { - delta.reasoning_content = parts.join(""); - } - } + copyOpenAICompatibleReasoningFields(deltaRecord, delta); if (deltaRecord.tool_calls !== undefined) { delta.tool_calls = Array.isArray(deltaRecord.tool_calls) ? deltaRecord.tool_calls.map((tc) => { diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index d3708fa4be..16c9c27d41 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -101,6 +101,17 @@ export async function handleVideoGeneration({ body, credentials, log }) { }); } + if (providerConfig.format === "dashscope-video") { + return handleDashscopeVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + return { success: false, status: 400, @@ -108,6 +119,177 @@ export async function handleVideoGeneration({ body, credentials, log }) { }; } +/** + * Alibaba (DashScope) Wan video generation: create async task → poll → MP4. + * Targets wan2.7-t2v on the DashScope intl region. Reuses the stored alibaba + * provider Bearer apiKey — no separate credential flow. + */ +async function handleDashscopeVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: { + model: string; + provider: string; + providerConfig: { baseUrl: string; statusUrl?: string }; + body: Record & { + prompt?: unknown; + negative_prompt?: unknown; + size?: unknown; + aspect_ratio?: unknown; + duration?: unknown; + timeout_ms?: unknown; + poll_interval_ms?: unknown; + }; + credentials?: { apiKey?: string; accessToken?: string } | null; + log?: { + info: (scope: string, message: string) => void; + error: (scope: string, message: string) => void; + } | null; +}) { + const startTime = Date.now(); + const timeoutMs = Number(body.timeout_ms) > 0 ? Number(body.timeout_ms) : 300000; + const pollIntervalMs = Number(body.poll_interval_ms) > 0 ? Number(body.poll_interval_ms) : 2500; + const token = credentials?.apiKey || credentials?.accessToken; + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const statusUrl = (providerConfig.statusUrl || `${baseUrl}/tasks`).replace(/\/$/, ""); + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + + if (!token) { + return { success: false, status: 401, error: "Alibaba DashScope API key is required" }; + } + + const sizeParam = normalizeDashscopeSize(body.size, body.aspect_ratio); + const parameters: Record = {}; + if (sizeParam) parameters.size = sizeParam; + if (body.duration != null) parameters.duration = Number(body.duration); + + const payload = { + model, + input: { + prompt, + ...(typeof body.negative_prompt === "string" + ? { negative_prompt: body.negative_prompt } + : {}), + }, + parameters, + }; + + if (log) { + log.info( + "VIDEO", + `${provider}/${model} (dashscope-video) | prompt: "${prompt.slice(0, 60)}..."` + ); + } + + try { + // Step 1: create async task (X-DashScope-Async: enable) + const createRes = await fetch(`${baseUrl}/services/aigc/video-generation/video-synthesis`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + }, + body: JSON.stringify(payload), + }); + const createData = await createRes.json().catch(() => ({})); + const taskId = createData?.output?.task_id; + if (!taskId) { + const errorMessage = + createData?.message || + createData?.errors?.[0]?.message || + "DashScope video generation did not return task_id"; + if (log) { + log.error("VIDEO", `DashScope createTask failed: ${JSON.stringify(createData)}`); + } + return { success: false, status: 502, error: String(errorMessage) }; + } + + // Step 2: poll statusUrl/{task_id} until terminal + const deadline = startTime + timeoutMs; + let lastStatus = "PENDING"; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + const pollRes = await fetch(`${statusUrl}/${taskId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const pollData = await pollRes.json().catch(() => ({})); + lastStatus = pollData?.output?.task_status || "PENDING"; + + if (lastStatus === "SUCCEEDED") { + const videoUrl = pollData?.output?.video_url; + if (!videoUrl) { + return { + success: false, + status: 502, + error: "DashScope task SUCCEEDED but no video_url", + }; + } + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { videos_count: 1 }, + }).catch(() => {}); + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ url: videoUrl, format: "mp4" }], + }, + }; + } + + if (lastStatus === "FAILED" || lastStatus === "UNKNOWN_ERROR") { + const errorMessage = + pollData?.output?.message || + pollData?.output?.errors?.[0]?.message || + "DashScope video task FAILED"; + return { success: false, status: 502, error: String(errorMessage) }; + } + // PENDING / RUNNING → keep polling + } + + return { + success: false, + status: 504, + error: `DashScope task ${taskId} timed out (status: ${lastStatus})`, + }; + } catch (err: unknown) { + return { + success: false, + status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502, + error: sanitizeErrorMessage(err) || "Video provider error", + }; + } +} + +// Map OmniRoute size/aspect_ratio → Alibaba DashScope "WxH" (1280*720). +// Accepts "1280*720", "1280x720", or a ratio "16:9". Returns undefined if unparseable +// (then omitted from the payload so DashScope applies its own default). +function normalizeDashscopeSize(size: unknown, aspectRatio: unknown): string | undefined { + if (typeof size === "string") { + if (/^\d+\*\d+$/.test(size)) return size; + if (/^\d+x\d+$/.test(size)) return size.replace("x", "*"); + } + if (typeof aspectRatio === "string") { + const ratioMap: Record = { + "16:9": "1280*720", + "9:16": "720*1280", + "1:1": "960*960", + }; + return ratioMap[aspectRatio]; + } + return undefined; +} + /** * Veo video generation via Vertex AI (predictLongRunning → poll → MP4). * Uses the Vertex chat credentials (Service Account JSON or Express key). diff --git a/open-sse/mcp-server/tools/poolTools.ts b/open-sse/mcp-server/tools/poolTools.ts index 4437e68f88..7c217db8e8 100644 --- a/open-sse/mcp-server/tools/poolTools.ts +++ b/open-sse/mcp-server/tools/poolTools.ts @@ -12,6 +12,7 @@ import { z } from "zod"; import { PoolRegistry } from "../../services/sessionPool/poolRegistry.ts"; import { getWebSessionPoolHealth } from "../../services/webSessionPoolHealth.ts"; +import { getBrowserPoolMetrics } from "../../services/browserPool.ts"; // ─── Input Schemas ───────────────────────────────────────────────────────── @@ -140,6 +141,16 @@ export async function handlePoolHealth( return report as unknown as Record; } +export const browserPoolStatusInput = z.object({}); + +/** + * Handle browser_pool_status tool (#3368 PR7): return the stealth browser + * pool's live status plus cumulative lifecycle telemetry. + */ +export async function handleBrowserPoolStatus(): Promise> { + return getBrowserPoolMetrics(); +} + // ─── Tool Registry ───────────────────────────────────────────────────────── export const poolTools = { @@ -183,4 +194,12 @@ export const poolTools = { inputSchema: poolHealthInput, handler: (args: z.infer) => handlePoolHealth(args), }, + omniroute_browser_pool_status: { + name: "omniroute_browser_pool_status", + description: + "Returns the stealth browser pool's live status (enabled, active contexts, browser running, stealth available, idle age) plus cumulative lifecycle telemetry: browser launches/failures, context create/reuse/evict/release counts, context-create failures, and shutdowns with the last reason.", + scopes: ["read:health"], + inputSchema: browserPoolStatusInput, + handler: () => handleBrowserPoolStatus(), + }, }; diff --git a/open-sse/package.json b/open-sse/package.json index 9329fca7a4..5ad62f9837 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.37", + "version": "3.8.38", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 696fdab687..37792749cc 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -18,6 +18,7 @@ import { DEFAULT_RESILIENCE_SETTINGS, resolveResilienceSettings, } from "../../src/lib/resilience/settings"; +import { resolveModelLockoutSettings } from "../../src/lib/resilience/modelLockoutSettings"; import { getAllCircuitBreakerStatuses, getCircuitBreaker, @@ -35,8 +36,8 @@ import { isRpdExhausted, isRpmExhausted } from "./geminiRateLimitTracker.ts"; export type ProviderProfile = { baseCooldownMs: number; useUpstreamRetryHints: boolean; - /** Issue #2100 follow-up. Stored override; undefined → per-provider default. */ useUpstream429BreakerHints?: boolean; + maxCooldownMs: number; maxBackoffSteps: number; failureThreshold: number; resetTimeoutMs: number; @@ -323,9 +324,8 @@ function buildProviderProfile( return { baseCooldownMs: connectionCooldown.baseCooldownMs, useUpstreamRetryHints: connectionCooldown.useUpstreamRetryHints, - // Issue #2100 follow-up: propagate stored override (boolean | undefined) - // so the runtime resolver picks user setting first, then per-provider default. useUpstream429BreakerHints: connectionCooldown.useUpstream429BreakerHints, + maxCooldownMs: resolveModelLockoutSettings(settings).maxCooldownMs, maxBackoffSteps: connectionCooldown.maxBackoffSteps, failureThreshold: providerBreaker.failureThreshold, resetTimeoutMs: providerBreaker.resetTimeoutMs, diff --git a/open-sse/services/browserPool.ts b/open-sse/services/browserPool.ts index c8ba5f3491..5343205cea 100644 --- a/open-sse/services/browserPool.ts +++ b/open-sse/services/browserPool.ts @@ -48,6 +48,36 @@ export interface PooledContext { isStealth: boolean; } +// #3368 PR7 — lightweight, cumulative browser-pool telemetry. Counters are +// incremented at lifecycle points and surfaced via getBrowserPoolMetrics() +// (and the omniroute_browser_pool_status MCP tool), giving the previously +// caller-less getBrowserPoolStatus() an observability home. +export interface BrowserPoolMetrics { + browserLaunches: number; + browserLaunchFailures: number; + contextsCreated: number; + contextsReused: number; + contextsEvicted: number; + contextsReleased: number; + contextCreateFailures: number; + shutdowns: number; + lastShutdownReason: string | null; +} + +function createBrowserPoolMetrics(): BrowserPoolMetrics { + return { + browserLaunches: 0, + browserLaunchFailures: 0, + contextsCreated: 0, + contextsReused: 0, + contextsEvicted: 0, + contextsReleased: 0, + contextCreateFailures: 0, + shutdowns: 0, + lastShutdownReason: null, + }; +} + interface PoolState { browser: Browser | null; contexts: Map; @@ -58,6 +88,7 @@ interface PoolState { evictTimer: NodeJS.Timeout | null; cloakLaunch: ((opts: unknown) => Promise) | null; cloakLaunchResolved: boolean; + metrics: BrowserPoolMetrics; } const POOL_IDLE_TIMEOUT_MS = 5 * 60 * 1000; @@ -76,6 +107,7 @@ const state: PoolState = { evictTimer: null, cloakLaunch: null, cloakLaunchResolved: false, + metrics: createBrowserPoolMetrics(), }; function getCloakbrowserModuleId(): string { @@ -123,6 +155,7 @@ function evictStaleContexts(): void { ((now - pooled.lastUsed) / 1000).toFixed(0) + "s)" ); state.contexts.delete(key); + state.metrics.contextsEvicted++; pooled.context.close().catch(() => {}); } } @@ -164,10 +197,17 @@ export async function resolvePlaywrightProxy( const p = await resolver(providerKey); if (!p?.host) return undefined; const scheme = p.type === "socks5" ? "socks5" : "http"; - return { + // Build explicitly instead of a conditional object spread: the spread form + // widens username/password to `{}` under the LaunchOptions["proxy"] type, + // tripping typecheck once browserPool.ts is pulled into typecheck-core scope. + const proxy: NonNullable = { server: `${scheme}://${p.host}:${p.port}`, - ...(p.username ? { username: p.username, password: p.password ?? "" } : {}), }; + if (p.username) { + proxy.username = String(p.username); + proxy.password = p.password == null ? "" : String(p.password); + } + return proxy; } catch (err) { console.warn("[BrowserPool] Failed to resolve proxy from DB:", err); return undefined; @@ -200,12 +240,14 @@ async function launchBrowser(): Promise { } state.browser = browser; state.launching = null; + state.metrics.browserLaunches++; return browser; })(); try { return await state.launching; } catch (err) { state.launching = null; + state.metrics.browserLaunchFailures++; throw err; } } @@ -256,6 +298,14 @@ function parseCookieString( }>; } +// Clear a key from the pending-creation map once its promise settles, counting +// failures. Kept as a leaf helper so acquireBrowserContext stays under the +// function-length ceiling (#3368 PR7 metrics). +function settlePendingContext(key: string, failed: boolean): void { + if (failed) state.metrics.contextCreateFailures++; + state.pendingContexts.delete(key); +} + export async function acquireBrowserContext( key: string, options: BrowserPoolContextOptions @@ -269,6 +319,7 @@ export async function acquireBrowserContext( if (existing) { existing.lastUsed = Date.now(); state.lastActivity = Date.now(); + state.metrics.contextsReused++; resetIdleTimer(); return existing; } @@ -337,6 +388,7 @@ export async function acquireBrowserContext( isStealth, }; state.contexts.set(key, pooled); + state.metrics.contextsCreated++; state.lastActivity = Date.now(); resetIdleTimer(); startEvictTimer(); @@ -345,8 +397,8 @@ export async function acquireBrowserContext( state.pendingContexts.set(key, createPromise); createPromise - .then(() => state.pendingContexts.delete(key)) - .catch(() => state.pendingContexts.delete(key)); + .then(() => settlePendingContext(key, false)) + .catch(() => settlePendingContext(key, true)); return createPromise; } @@ -359,6 +411,7 @@ export async function releaseBrowserContext(key: string): Promise { const pooled = state.contexts.get(key); if (!pooled) return; state.contexts.delete(key); + state.metrics.contextsReleased++; try { await pooled.context.close(); } catch { @@ -370,6 +423,8 @@ export async function releaseBrowserContext(key: string): Promise { } export async function shutdownPool(reason: string): Promise { + state.metrics.shutdowns++; + state.metrics.lastShutdownReason = reason; if (state.idleTimer) { clearTimeout(state.idleTimer); state.idleTimer = null; @@ -417,6 +472,23 @@ export function getBrowserPoolStatus(): { }; } +/** + * #3368 PR7 — browser-pool observability. Returns live status plus cumulative + * lifecycle telemetry (launches, context create/reuse/evict/release counts, + * failures, shutdowns). Surfaced via the omniroute_browser_pool_status MCP tool. + */ +export function getBrowserPoolMetrics(): { + status: ReturnType; + metrics: BrowserPoolMetrics; +} { + return { status: getBrowserPoolStatus(), metrics: { ...state.metrics } }; +} + +/** Test-only: reset cumulative metrics so assertions start from a clean slate. */ +export function __resetBrowserPoolMetricsForTest(): void { + state.metrics = createBrowserPoolMetrics(); +} + export async function readPageResponseBody( response: import("playwright").Response ): Promise<{ status: number; headers: Record; body: Buffer }> { diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index c8f0788861..a23642409a 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -12,6 +12,7 @@ import { disableThinkingIfToolChoiceForced, enforceCacheControlLimit, } from "./claudeCodeConstraints.ts"; +import { applyClaudeCodeCompatibleThinkingDisplay } from "./claudeCodeCompatibleThinkingDisplay.ts"; import { obfuscateInBody } from "./claudeCodeObfuscation.ts"; import { applySystemTransformPipeline, PROVIDER_CC_BRIDGE } from "./systemTransforms.ts"; import { @@ -45,7 +46,6 @@ export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.187 (external, export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.94.0"; export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0"; export const CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07"; -const COPILOT_REASONING_SUMMARY_MARKER = "_omnirouteCopilotReasoningSummary"; const CLAUDE_CODE_COMPATIBLE_DEFAULT_SYSTEM_BLOCKS = [ { type: "text", @@ -86,6 +86,7 @@ type BuildRequestOptions = { preserveCacheControl?: boolean; preserveClaudeMessages?: boolean; redactThinking?: boolean; + summarizeThinking?: boolean; }; function supportsClaudeXHighEffort(model: string | null | undefined): boolean { @@ -239,7 +240,7 @@ export function buildClaudeCodeCompatibleRequest({ sessionId, preserveCacheControl = false, preserveClaudeMessages = false, - redactThinking = false, + summarizeThinking = false, }: BuildRequestOptions) { const normalized = normalizedBody || {}; const preparedClaudeBody = claudeBody @@ -297,6 +298,7 @@ export function buildClaudeCodeCompatibleRequest({ claudeBody: preparedClaudeBody ?? claudeBody, sourceBody, normalizedBody, + summarizeThinking, }); const outputConfig = resolveClaudeCodeCompatibleOutputConfig({ claudeBody, @@ -1051,10 +1053,12 @@ function resolveClaudeCodeCompatibleThinking({ claudeBody, sourceBody, normalizedBody, + summarizeThinking = false, }: { claudeBody?: Record | null; sourceBody?: Record | null; normalizedBody?: Record | null; + summarizeThinking?: boolean; }) { const thinking = readRecord(cloneValue(claudeBody?.thinking)) || @@ -1062,33 +1066,23 @@ function resolveClaudeCodeCompatibleThinking({ readRecord(cloneValue(normalizedBody?.thinking)); if (thinking) { - return applyClaudeCodeCompatibleThinkingDisplay(thinking, normalizedBody); + return applyClaudeCodeCompatibleThinkingDisplay(thinking, { + normalizedBody, + summarizeThinking, + }); } return applyClaudeCodeCompatibleThinkingDisplay( { type: "adaptive", }, - normalizedBody + { + normalizedBody, + summarizeThinking, + } ); } -function applyClaudeCodeCompatibleThinkingDisplay( - thinking: Record, - normalizedBody?: Record | null -) { - if ( - normalizedBody?.[COPILOT_REASONING_SUMMARY_MARKER] !== "summarized" || - thinking.type === "disabled" - ) { - return thinking; - } - return { - ...thinking, - display: "summarized", - }; -} - function resolveClaudeCodeCompatibleOutputConfig({ claudeBody, sourceBody, diff --git a/open-sse/services/claudeCodeCompatibleThinkingDisplay.ts b/open-sse/services/claudeCodeCompatibleThinkingDisplay.ts new file mode 100644 index 0000000000..d457956e0a --- /dev/null +++ b/open-sse/services/claudeCodeCompatibleThinkingDisplay.ts @@ -0,0 +1,34 @@ +const COPILOT_REASONING_SUMMARY_MARKER = "_omnirouteCopilotReasoningSummary"; + +export function applyClaudeCodeCompatibleThinkingDisplay( + thinking: Record, + options: { + normalizedBody?: Record | null; + summarizeThinking?: boolean; + } = {} +) { + if (thinking.type === "disabled") { + return thinking; + } + + const markerRequestsSummary = + options.normalizedBody?.[COPILOT_REASONING_SUMMARY_MARKER] === "summarized"; + const connectionRequestsSummary = options.summarizeThinking === true; + if (!markerRequestsSummary && !connectionRequestsSummary) { + return thinking; + } + + const hasExplicitDisplay = + Object.prototype.hasOwnProperty.call(thinking, "display") && + thinking.display !== undefined && + thinking.display !== null && + String(thinking.display).trim().length > 0; + if (hasExplicitDisplay && !markerRequestsSummary) { + return thinking; + } + + return { + ...thinking, + display: "summarized", + }; +} diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 8aea1e03d0..1f6a520d04 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -203,19 +203,13 @@ import { reorderByTaskWeight, } from "./taskAwareRouting.ts"; -// Backward-compatible re-exports — these were public from combo.ts before the -// types extraction (Quality Gate v2 / Fase 9). Keep the external surface stable. export { RESET_WINDOW_NAMES }; -// chatCore.ts's dynamic `import("../services/combo")` reads these two — keep them -// re-exported from combo.ts after the auto-strategy extraction (combo split D8). export { QUOTA_SOFT_DEPRIORITIZE_FACTOR, setCandidateQuotaSoftPenalty }; export { scoreAutoTargets, expandAutoComboCandidatePool }; export type { SingleModelTarget, ResolvedComboTarget }; export { validateResponseQuality }; export { clampComboDepth, shouldSkipForPredictedTtft, shouldRecordProviderBreakerFailure }; export { resolveShadowTargets, scheduleShadowRouting }; -// preScreenTargets was public from combo.ts before the reset-aware quota -// extraction (combo split D7b). Keep the external surface stable. export { preScreenTargets }; export { resolveComboRuntimeUnits, resolveComboTargets, filterTargetsByRequestCompatibility }; export { @@ -226,15 +220,6 @@ export { validateComboDAG, } from "./combo/comboStructure.ts"; -// Reset-aware / reset-window quota config, scoring, and window-math helpers were -// extracted to combo/quotaScoring.ts (pure) and the stateful cache + strategy -// orderers to combo/quotaStrategies.ts (combo split D7b). The two cache Maps -// (resetAwareConnectionCache, resetAwareQuotaCache) live as single instances in -// quotaStrategies.ts alongside their only readers/writers (state cohesion). -// combo.ts imports back the three reset-window helpers buildAutoCandidates + -// orchestration need, plus the strategy orderers and preScreenTargets (above). - -// Bootstrap defaults from ClawRouter benchmark (used when no local latency history exists yet) const DEFAULT_MODEL_P95_MS: Record = { "grok-4-fast-non-reasoning": 1143, "grok-4-1-fast-non-reasoning": 1244, @@ -246,9 +231,6 @@ const DEFAULT_MODEL_P95_MS: Record = { "deepseek-chat": 2000, }; const MIN_HISTORY_SAMPLES = 10; -// Assumed fraction of tokens that are output when blending input+output prices -// for auto-combo cost scoring. 0.4 = 40% output, 60% input. -// Matches the example in GitHub issue #1812 (e.g. o3-like model: $3 input/$15 output). const OUTPUT_TOKEN_RATIO = 0.4; function normalizeNestedComboMode(value: unknown): NestedComboMode { @@ -444,11 +426,22 @@ export async function buildAutoCandidates( const connectionIds = providerConnections .map((c) => (c && typeof c === "object" && typeof c.id === "string" ? c.id : null)) .filter((id): id is string => id !== null); - if (connectionIds.length === 0) { + const allowedConnectionIds = Array.isArray(target.allowedConnectionIds) + ? new Set( + target.allowedConnectionIds.filter( + (connectionId): connectionId is string => + typeof connectionId === "string" && connectionId.trim().length > 0 + ) + ) + : null; + const scopedConnectionIds = allowedConnectionIds + ? connectionIds.filter((connectionId) => allowedConnectionIds.has(connectionId)) + : connectionIds; + if (scopedConnectionIds.length === 0) { expandedTargets.push(target); continue; } - for (const connectionId of connectionIds) { + for (const connectionId of scopedConnectionIds) { expandedTargets.push({ ...target, connectionId, @@ -727,10 +720,6 @@ export async function handleComboChat({ apiKeyAllowedConnections = null, nesting = null, }: HandleComboChatOptions): Promise { - // Combo setup phase (god-file decomposition fase 1): strategy / relay / resilience / - // universal-handoff / context-cache pinning / agent middleware / config cascade / timeout. - // phaseComboSetup rewrites ctx.body (pinning + middleware); rebind `body` from it so the - // rest of handleComboChat is unchanged. See combo/comboSetup.ts + combo/context.ts. const comboCtx = createComboContext({ body, combo, settings, relayOptions, log }); const { strategy, @@ -746,16 +735,6 @@ export async function handleComboChat({ } = phaseComboSetup(comboCtx); body = comboCtx.body; - // ── Per-model timeout wrapper ──────────────────────────────────────────── - // Combo target timeouts inherit FETCH_TIMEOUT_MS by default. Operators can - // configure targetTimeoutMs to shorten fallback latency, but never to extend - // beyond the current upstream request timeout. - // - // The timeoutController is forwarded to the inner caller via target.modelAbortSignal. - // When the timeout fires we (a) resolve the race with a synthetic 524 and - // (b) abort the inner request so its upstream fetch is cancelled and downstream - // cooldown/breaker/usage mutations stop — preventing "ghost" state mutations - // that diverge from the routing decision the operator sees. const handleSingleModelWithTimeout = async ( b: Record, modelStr: string, @@ -777,9 +756,6 @@ export async function handleComboChat({ "COMBO", `Model ${modelStr} exceeded ${comboTargetTimeoutMs}ms timeout — falling back` ); - // Abort the inner request so its upstream fetch is cancelled and - // downstream cooldown/breaker/usage mutations don't continue mutating - // state behind the routing decision's back. timeoutController.abort(new Error("combo-per-model-timeout")); resolve( new Response(JSON.stringify({ error: { message: `Model ${modelStr} timed out` } }), { @@ -820,9 +796,6 @@ export async function handleComboChat({ ]); } finally { clearTimeout(timeoutId); - // Detach our listener from the SHARED parent hedge signal. Without this, every target - // attempt left a listener on the long-lived parent signal for the whole request, so a - // request that tries many combo targets accumulated listeners on one signal. if (parentHedgeSignal && onParentHedgeAbort) { parentHedgeSignal.removeEventListener("abort", onParentHedgeAbort); } diff --git a/open-sse/services/combo/targetExhaustion.ts b/open-sse/services/combo/targetExhaustion.ts index fb4a371b3c..bf3d69dc57 100644 --- a/open-sse/services/combo/targetExhaustion.ts +++ b/open-sse/services/combo/targetExhaustion.ts @@ -24,6 +24,16 @@ import type { ComboLogger, ResolvedComboTarget } from "./types.ts"; // unreachable, proxy/gateway error), so remaining same-connection targets are skipped. const CONNECTION_LEVEL_ERROR_STATUSES = [408, 500, 502, 503, 504, 524]; +// #5085: an "empty content" 502 is the synthetic status chatCore assigns to a provider that +// answered HTTP 200 with no usable completion (isEmptyContentResponse). The connection is +// HEALTHY — it just returned an empty body — so this must NOT be classified as a connection +// failure (which would exhaust the whole provider/connection and skip every remaining +// same-provider leg via #1731v2). It is a model-level transient failure: advance to the next +// leg, leaving the rest of that provider's legs eligible. +function isEmptyContentFailure(status: number, errorText: string): boolean { + return status === 502 && /empty content/i.test(errorText); +} + export type ComboExhaustionSets = { exhaustedProviders: Set; exhaustedConnections: Set; @@ -106,7 +116,11 @@ function markConnectionLevelExhaustion( !provider || provider === "unknown" || !CONNECTION_LEVEL_ERROR_STATUSES.includes(result.status) || - isProviderCircuitOpenResult(result, errorText) + isProviderCircuitOpenResult(result, errorText) || + // #5085: empty-content 502 is a healthy connection returning no body — model-level, not + // connection-level. Don't exhaust the provider; let the remaining legs (incl. same-provider) + // be tried in-request. + isEmptyContentFailure(result.status, errorText) ) { return; } diff --git a/open-sse/services/compression/engines/index.ts b/open-sse/services/compression/engines/index.ts index 4be7b5f93e..239aad8bb2 100644 --- a/open-sse/services/compression/engines/index.ts +++ b/open-sse/services/compression/engines/index.ts @@ -5,6 +5,7 @@ import { sessionDedupEngine } from "./session-dedup/index.ts"; import { headroomEngine } from "./headroom/index.ts"; import { ccrEngine } from "./ccr/index.ts"; import { llmlinguaEngine } from "./llmlingua/index.ts"; +import { ionizerEngine } from "./ionizer/index.ts"; let registered = false; @@ -26,6 +27,7 @@ export function registerBuiltinCompressionEngines(): void { { id: "headroom", engine: headroomEngine }, { id: "ccr", engine: ccrEngine }, { id: "llmlingua", engine: llmlinguaEngine }, + { id: "ionizer", engine: ionizerEngine }, ]; for (const { id, engine } of engines) { diff --git a/open-sse/services/compression/engines/ionizer/index.ts b/open-sse/services/compression/engines/ionizer/index.ts new file mode 100644 index 0000000000..36945dff2a --- /dev/null +++ b/open-sse/services/compression/engines/ionizer/index.ts @@ -0,0 +1,124 @@ +// open-sse/services/compression/engines/ionizer/index.ts +import { createCompressionStats } from "../../stats.ts"; +import { runIonizerPass } from "./sample.ts"; +import type { + CompressionEngine, + CompressionEngineApplyOptions, + EngineConfigField, + EngineValidationResult, +} from "../types.ts"; +import type { CompressionResult } from "../../types.ts"; + +const ENGINE_ID = "ionizer"; + +type MessageLike = { role?: string; content?: unknown; [key: string]: unknown }; + +const IONIZER_SCHEMA: EngineConfigField[] = [ + { key: "enabled", type: "boolean", label: "Enabled", defaultValue: true }, + { + key: "threshold", + type: "number", + label: "Row threshold", + description: "Only arrays with more than this many object rows are sampled. Default: 200.", + defaultValue: 200, + min: 2, + max: 1000000, + }, + { + key: "targetRows", + type: "number", + label: "Target kept rows", + description: "Approximate number of rows kept inline after sampling. Default: 50.", + defaultValue: 50, + min: 1, + max: 100000, + }, +]; + +function validateIonizerConfig(config: Record): EngineValidationResult { + const errors: string[] = []; + if (config["enabled"] !== undefined && typeof config["enabled"] !== "boolean") { + errors.push("enabled must be a boolean"); + } + for (const k of ["threshold", "targetRows"]) { + if (config[k] !== undefined) { + const v = config[k]; + if (typeof v !== "number" || !Number.isFinite(v) || v < 1) { + errors.push(`${k} must be a positive number`); + } + } + } + return { valid: errors.length === 0, errors }; +} + +export const ionizerEngine: CompressionEngine = { + id: ENGINE_ID, + name: "Ionizer", + description: + "Lossy statistical sampling of oversized homogeneous JSON arrays. Keeps schema + error rows " + + "+ first/last rows + a seeded uniform middle sample inline; stores the whole array in CCR " + + "for recovery. Complements headroom (lossless) as the fallback when columnar still overflows.", + icon: "filter_alt", + targets: ["messages"], + stackable: true, + // stackPriority 13 = between rtk (10) and headroom (15): sample raw rows BEFORE headroom + // losslessly compacts the survivors. + stackPriority: 13, + sampling: true, + metadata: { + id: ENGINE_ID, + name: "Ionizer", + description: + "Lossy statistical sampling of oversized homogeneous JSON arrays, reversible via CCR.", + inputScope: "messages", + targetLatencyMs: 2, + supportsPreview: true, + stable: true, + }, + + apply(body: Record, options?: CompressionEngineApplyOptions): CompressionResult { + const stepConfig = options?.stepConfig ?? {}; + if (stepConfig["enabled"] === false) { + return { body, compressed: false, stats: null }; + } + const messages = body["messages"]; + if (!Array.isArray(messages) || messages.length === 0) { + return { body, compressed: false, stats: null }; + } + + const start = performance.now(); + const { messages: finalMessages, ionizedCount } = runIonizerPass( + messages as MessageLike[], + stepConfig, + options?.principalId + ); + + if (ionizedCount === 0) { + return { body, compressed: false, stats: null }; + } + + const newBody: Record = { ...body, messages: finalMessages }; + const durationMs = Math.round(performance.now() - start); + const stats = createCompressionStats( + body, + newBody, + "stacked", + ["ionizer"], + [`ionizer-${ionizedCount}-arrays-sampled`], + durationMs + ); + return { body: newBody, compressed: true, stats }; + }, + + compress(body: Record, config?: Record): CompressionResult { + return this.apply(body, { stepConfig: config ?? {} }); + }, + + getConfigSchema(): EngineConfigField[] { + return IONIZER_SCHEMA; + }, + + validateConfig(config: Record): EngineValidationResult { + return validateIonizerConfig(config); + }, +}; diff --git a/open-sse/services/compression/engines/ionizer/sample.ts b/open-sse/services/compression/engines/ionizer/sample.ts new file mode 100644 index 0000000000..172429d2a8 --- /dev/null +++ b/open-sse/services/compression/engines/ionizer/sample.ts @@ -0,0 +1,200 @@ +// open-sse/services/compression/engines/ionizer/sample.ts +import { storeBlock } from "../ccr/index.ts"; + +type MessageLike = { role?: string; content?: unknown; [key: string]: unknown }; + +/** Hard cap on rows parsed by the O(n) pass (fail-safe bound). */ +export const MAX_IONIZER_ROWS = 100000; + +/** FNV-1a 32-bit hash (deterministic, cheap, no Math.random) — seeds the middle sample. */ +export function fnv1a(s: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** mulberry32 PRNG — deterministic, seeded (no Math.random). */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Union of all keys across rows, in first-seen order. */ +export function schemaUnion(rows: Array>): string[] { + const seen = new Set(); + const out: string[] = []; + for (const row of rows) { + for (const key of Object.keys(row)) { + if (!seen.has(key)) { + seen.add(key); + out.push(key); + } + } + } + return out; +} + +/** Structural heuristic: does this row represent an error/failure? (always kept) */ +export function isErrorRow(row: Record): boolean { + for (const key of Object.keys(row)) { + if (/error|fail|exception|stderr|denied/i.test(key) && row[key]) return true; + } + for (const k of ["status", "statusCode", "code"]) { + const v = row[k]; + if (typeof v === "number" && v >= 400 && v <= 599) return true; + } + return false; +} + +/** k deterministic, seeded elements of `pool`, preserving pool order. k>=n → all. */ +export function seededSample(pool: T[], k: number, seed: number): T[] { + if (k >= pool.length) return pool.slice(); + const idx = pool.map((_, i) => i); + const rand = mulberry32(seed); + for (let i = 0; i < k; i++) { + const j = i + Math.floor(rand() * (idx.length - i)); + const tmp = idx[i]; + idx[i] = idx[j]; + idx[j] = tmp; + } + return idx + .slice(0, k) + .sort((a, b) => a - b) + .map((i) => pool[i]); +} + +export interface IonizeOptions { + targetRows: number; + firstK: number; + lastK: number; + seed: number; +} +export interface IonizeResult { + kept: Array>; + keptCount: number; + totalCount: number; +} + +/** + * Pick a representative subset of rows: schema-cover (first row introducing each new key) + * ∪ ALL error rows ∪ first-K ∪ last-K ∪ a seeded uniform sample of the remaining middle, + * up to targetRows. Deterministic. Returns the kept rows in ORIGINAL order. + */ +export function ionize(rows: Array>, opts: IonizeOptions): IonizeResult { + const n = rows.length; + if (n <= opts.targetRows) return { kept: rows, keptCount: n, totalCount: n }; + + const keep = new Set(); + const seenKeys = new Set(); + for (let i = 0; i < n; i++) { + let novel = false; + for (const key of Object.keys(rows[i])) { + if (!seenKeys.has(key)) { + seenKeys.add(key); + novel = true; + } + } + if (novel) keep.add(i); + } + for (let i = 0; i < n; i++) if (isErrorRow(rows[i])) keep.add(i); + for (let i = 0; i < Math.min(opts.firstK, n); i++) keep.add(i); + for (let i = Math.max(0, n - opts.lastK); i < n; i++) keep.add(i); + if (keep.size < opts.targetRows) { + const middle: number[] = []; + for (let i = 0; i < n; i++) if (!keep.has(i)) middle.push(i); + const need = opts.targetRows - keep.size; + for (const idx of seededSample(middle, need, opts.seed)) keep.add(idx); + } + + const keptIdx = [...keep].sort((a, b) => a - b); + return { kept: keptIdx.map((i) => rows[i]), keptCount: keptIdx.length, totalCount: n }; +} + +export interface IonizerPassOptions { + threshold: number; + targetRows: number; + principalId?: string; +} +export interface IonizerPassResult { + messages: MessageLike[]; + ionizedCount: number; +} + +function isPlainObjectArray(v: unknown): v is Array> { + return ( + Array.isArray(v) && + v.every((el) => el !== null && typeof el === "object" && !Array.isArray(el)) + ); +} + +/** + * For each non-system string-content message that parses as a homogeneous array of plain + * objects longer than `threshold`, replace it with a deterministic inline sample + a recoverable + * CCR marker (the whole original array stored via storeBlock). Only when the marker shrinks it. + * FAIL-OPEN: any error → no-op. + */ +export function applyIonizerPass( + messages: MessageLike[], + opts: IonizerPassOptions +): IonizerPassResult { + try { + let ionizedCount = 0; + const out = messages.map((m) => { + if (m.role === "system" || typeof m.content !== "string") return m; + const serialized = m.content; + let parsed: unknown; + try { + parsed = JSON.parse(serialized); + } catch { + return m; + } + if (!Array.isArray(parsed) || parsed.length <= opts.threshold) return m; + if (parsed.length > MAX_IONIZER_ROWS) return m; + if (!isPlainObjectArray(parsed)) return m; + + const res = ionize(parsed, { + targetRows: opts.targetRows, + firstK: 3, + lastK: 3, + seed: fnv1a(serialized), + }); + if (res.keptCount >= res.totalCount) return m; + + const hash = storeBlock(serialized, opts.principalId); + const marker = `[ionizer: kept ${res.keptCount}/${res.totalCount} rows; full → CCR retrieve hash=${hash} chars=${serialized.length}]`; + const newContent = `${JSON.stringify(res.kept)}\n${marker}`; + if (newContent.length >= serialized.length) return m; + + ionizedCount++; + return { ...m, content: newContent }; + }); + return ionizedCount > 0 ? { messages: out, ionizedCount } : { messages, ionizedCount: 0 }; + } catch { + return { messages, ionizedCount: 0 }; + } +} + +/** + * Resolve the ionizer step-config off `stepConfig` (enabled / threshold / targetRows) and run the + * pass. Returns the messages unchanged + `ionizedCount: 0` when disabled. Keeps the engine's + * `apply()` thin (config normalization + dispatch live here). + */ +export function runIonizerPass( + messages: MessageLike[], + stepConfig: Record, + principalId?: string +): IonizerPassResult { + if (stepConfig["enabled"] === false) return { messages, ionizedCount: 0 }; + const threshold = typeof stepConfig["threshold"] === "number" ? (stepConfig["threshold"] as number) : 200; + const targetRows = typeof stepConfig["targetRows"] === "number" ? (stepConfig["targetRows"] as number) : 50; + return applyIonizerPass(messages, { threshold, targetRows, principalId }); +} diff --git a/open-sse/services/compression/engines/llmlingua/worker.ts b/open-sse/services/compression/engines/llmlingua/worker.ts index 4371aaee7f..c18150a171 100644 --- a/open-sse/services/compression/engines/llmlingua/worker.ts +++ b/open-sse/services/compression/engines/llmlingua/worker.ts @@ -33,11 +33,9 @@ import { Worker } from "node:worker_threads"; import path from "node:path"; import fs from "node:fs"; +import { pathToFileURL } from "node:url"; -import { - LLMLINGUA_WORKER_TIMEOUT_MS, - LLMLINGUA_WORKER_IDLE_MS, -} from "./constants.ts"; +import { LLMLINGUA_WORKER_TIMEOUT_MS, LLMLINGUA_WORKER_IDLE_MS } from "./constants.ts"; import { resolveLlmlinguaModel } from "./modelStore.ts"; import type { LlmlinguaBackend } from "./index.ts"; @@ -229,7 +227,8 @@ function ensureWorker(): Worker { if (worker) return worker; const { workerFile, execArgv } = resolveWorkerFile(); - const w = new Worker(workerFile, { execArgv }); + const absoluteWorkerFile = path.resolve(workerFile); + const w = new Worker(pathToFileURL(absoluteWorkerFile).href, { execArgv }); w.on("message", (reply: WorkerReply) => { const entry = pending.get(reply.id); diff --git a/open-sse/services/compression/engines/rtk/filterLoader.ts b/open-sse/services/compression/engines/rtk/filterLoader.ts index 5980fdb91f..aa10dd8224 100644 --- a/open-sse/services/compression/engines/rtk/filterLoader.ts +++ b/open-sse/services/compression/engines/rtk/filterLoader.ts @@ -1,5 +1,4 @@ import fs from "node:fs"; -import { fileURLToPath } from "node:url"; import path from "node:path"; import os from "node:os"; import crypto from "node:crypto"; @@ -47,22 +46,28 @@ interface RtkFilterLoadOptions { trustProjectFilters?: boolean; } +function getModuleDir(): string { + const anchors = [process.cwd()]; + const argv1 = process.argv[1]; + if (typeof argv1 === "string" && argv1) anchors.push(path.dirname(argv1)); + const rel = path.join("open-sse", "services", "compression"); + for (const anchor of anchors) { + let dir = path.resolve(anchor); + for (let i = 0; i <= 8; i++) { + if (fs.existsSync(path.join(dir, rel))) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + return path.join(os.homedir(), ".omniroute"); +} + function getFiltersDir(): string { - const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const root = getModuleDir(); const candidates = [ - path.join(moduleDir, "filters"), - path.join(moduleDir, "..", "services", "compression", "engines", "rtk", "filters"), - path.join(process.cwd(), "open-sse", "services", "compression", "engines", "rtk", "filters"), - path.join( - process.cwd(), - "app", - "open-sse", - "services", - "compression", - "engines", - "rtk", - "filters" - ), + path.join(root, "open-sse", "services", "compression", "engines", "rtk", "filters"), + path.join(root, "app", "open-sse", "services", "compression", "engines", "rtk", "filters"), ]; return ( candidates.find((candidate, index) => { diff --git a/open-sse/services/compression/engines/session-dedup/fuzzy.ts b/open-sse/services/compression/engines/session-dedup/fuzzy.ts new file mode 100644 index 0000000000..f61c2521c7 --- /dev/null +++ b/open-sse/services/compression/engines/session-dedup/fuzzy.ts @@ -0,0 +1,153 @@ +// open-sse/services/compression/engines/session-dedup/fuzzy.ts +import { storeBlock } from "../ccr/index.ts"; + +type MessageLike = { role?: string; content?: unknown; [key: string]: unknown }; + +/** Hard cap on blocks compared in the fuzzy O(n²) pass (fail-safe bound). */ +export const MAX_FUZZY_BLOCKS = 200; + +/** FNV-1a 32-bit hash (deterministic, cheap, no Math.random). */ +function fnv1a(s: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** Set of k-word shingle hashes (default k=3). Empty when the text has < k words. */ +export function shingles(text: string, k = 3): Set { + const words = text.split(/\s+/).filter(Boolean); + const out = new Set(); + if (words.length < k) return out; + for (let i = 0; i + k <= words.length; i++) { + out.add(fnv1a(words.slice(i, i + k).join(" "))); + } + return out; +} + +/** Jaccard similarity |a∩b| / |a∪b| (0..1; 0 when both empty). */ +export function jaccard(a: Set, b: Set): number { + if (a.size === 0 && b.size === 0) return 0; + const [small, large] = a.size <= b.size ? [a, b] : [b, a]; + let inter = 0; + for (const x of small) if (large.has(x)) inter++; + const union = a.size + b.size - inter; + return union === 0 ? 0 : inter / union; +} + +export interface FuzzyBlock { + text: string; + index: number; +} +export interface NearDuplicate { + block: FuzzyBlock; + matchedIndex: number; + similarity: number; +} + +/** + * For each block, find the EARLIER block with the highest Jaccard ≥ minJaccard. + * O(n²); returns [] when blocks.length > maxBlocks (fail-safe bound, no blowup). + */ +export function findNearDuplicates( + blocks: FuzzyBlock[], + minJaccard: number, + maxBlocks: number, + shingleSize = 3 +): NearDuplicate[] { + if (blocks.length > maxBlocks) return []; + const sets = blocks.map((b) => shingles(b.text, shingleSize)); + const out: NearDuplicate[] = []; + for (let i = 0; i < blocks.length; i++) { + let best = -1; + let bestSim = 0; + for (let j = 0; j < i; j++) { + const sim = jaccard(sets[i], sets[j]); + if (sim >= minJaccard && sim > bestSim) { + bestSim = sim; + best = j; + } + } + if (best >= 0) { + out.push({ block: blocks[i], matchedIndex: blocks[best].index, similarity: bestSim }); + } + } + return out; +} + +export interface FuzzyPassOptions { + minJaccard: number; + shingleSize: number; + maxBlocks: number; + minBlockChars: number; + principalId?: string; +} +export interface FuzzyPassResult { + messages: MessageLike[]; + fuzzyCount: number; +} + +/** + * Near-duplicate second pass over WHOLE string-content messages. A later message ≥minJaccard + * similar to an earlier one is stored in the CCR store and replaced inline with a recoverable + * `[CCR retrieve …]` marker (only when the marker shrinks). FAIL-OPEN: any error → no-op. + */ +export function applyFuzzyPass(messages: MessageLike[], opts: FuzzyPassOptions): FuzzyPassResult { + try { + const blocks: FuzzyBlock[] = []; + for (let i = 0; i < messages.length; i++) { + const m = messages[i]; + if (m.role === "system") continue; + if (typeof m.content === "string" && m.content.length >= opts.minBlockChars) { + blocks.push({ text: m.content, index: i }); + } + } + if (blocks.length < 2) return { messages, fuzzyCount: 0 }; + + const nearDups = findNearDuplicates(blocks, opts.minJaccard, opts.maxBlocks, opts.shingleSize); + if (nearDups.length === 0) return { messages, fuzzyCount: 0 }; + + const replacements = new Map(); + for (const nd of nearDups) { + const hash = storeBlock(nd.block.text, opts.principalId); + const marker = `[CCR retrieve hash=${hash} chars=${nd.block.text.length}]`; + if (marker.length < nd.block.text.length) replacements.set(nd.block.index, marker); + } + if (replacements.size === 0) return { messages, fuzzyCount: 0 }; + + const out = messages.map((m, i) => + replacements.has(i) ? { ...m, content: replacements.get(i) } : m + ); + return { messages: out, fuzzyCount: replacements.size }; + } catch { + return { messages, fuzzyCount: 0 }; + } +} + +/** + * Resolve the `fuzzy` step-config (a bare boolean OR `{ enabled, minJaccard?, shingleSize? }`) + * and run the near-duplicate pass. Returns the messages unchanged + `fuzzyCount: 0` when disabled. + * Keeps the engine's `apply()` thin (config normalization + dispatch live here). + */ +export function runFuzzyPass( + messages: MessageLike[], + stepConfig: Record, + minBlockChars: number, + principalId?: string +): FuzzyPassResult { + const raw = stepConfig["fuzzy"] as + | boolean + | { enabled?: boolean; minJaccard?: number; shingleSize?: number } + | undefined; + const cfg = typeof raw === "boolean" ? { enabled: raw } : raw; + if (!cfg?.enabled) return { messages, fuzzyCount: 0 }; + return applyFuzzyPass(messages, { + minJaccard: typeof cfg.minJaccard === "number" ? cfg.minJaccard : 0.85, + shingleSize: typeof cfg.shingleSize === "number" ? cfg.shingleSize : 3, + maxBlocks: MAX_FUZZY_BLOCKS, + minBlockChars, + principalId, + }); +} diff --git a/open-sse/services/compression/engines/session-dedup/index.ts b/open-sse/services/compression/engines/session-dedup/index.ts index 2ba7fe9f2b..4a2b2303c3 100644 --- a/open-sse/services/compression/engines/session-dedup/index.ts +++ b/open-sse/services/compression/engines/session-dedup/index.ts @@ -30,6 +30,7 @@ import crypto from "node:crypto"; import { createCompressionStats } from "../../stats.ts"; +import { runFuzzyPass } from "./fuzzy.ts"; import type { CompressionEngine, CompressionEngineApplyOptions, @@ -258,6 +259,14 @@ const SESSION_DEDUP_SCHEMA: EngineConfigField[] = [ min: 1, max: 100000, }, + { + key: "fuzzy", + type: "boolean", + label: "Fuzzy near-duplicate dedup", + description: + "Opt-in: replace whole messages ~85%+ similar to an earlier one with a recoverable CCR marker.", + defaultValue: false, + }, ]; function validateSessionDedupConfig(config: Record): EngineValidationResult { @@ -271,6 +280,15 @@ function validateSessionDedupConfig(config: Record): EngineVali errors.push("minBlockChars must be a positive number"); } } + if (config["fuzzy"] !== undefined) { + const f = config["fuzzy"]; + if (typeof f === "object" && f !== null) { + const fe = (f as Record)["enabled"]; + if (fe !== undefined && typeof fe !== "boolean") errors.push("fuzzy.enabled must be a boolean"); + } else if (typeof f !== "boolean") { + errors.push("fuzzy must be an object { enabled } or a boolean"); + } + } return { valid: errors.length === 0, errors }; } @@ -317,30 +335,30 @@ export const sessionDedupEngine: CompressionEngine = { } const start = performance.now(); - const { messages: dedupedMessages, dedupCount } = processMessages( + const { messages: exactMessages, dedupCount } = processMessages( messages as MessageLike[], minBlockChars ); - if (dedupCount === 0) { + const { messages: finalMessages, fuzzyCount } = runFuzzyPass( + exactMessages, + stepConfig, + minBlockChars, + options?.principalId + ); + + if (dedupCount + fuzzyCount === 0) { return { body, compressed: false, stats: null }; } - const newBody: Record = { - ...body, - messages: dedupedMessages, - }; - + const newBody: Record = { ...body, messages: finalMessages }; const durationMs = Math.round(performance.now() - start); - const stats = createCompressionStats( - body, - newBody, - "stacked", - ["session-dedup"], - [`deduplicated-${dedupCount}-blocks`], - durationMs - ); - + const techniques = ["session-dedup"]; + if (fuzzyCount > 0) techniques.push("fuzzy-dedup"); + const rules: string[] = []; + if (dedupCount > 0) rules.push(`deduplicated-${dedupCount}-blocks`); + if (fuzzyCount > 0) rules.push(`fuzzy-${fuzzyCount}-blocks`); + const stats = createCompressionStats(body, newBody, "stacked", techniques, rules, durationMs); return { body: newBody, compressed: true, stats }; }, diff --git a/open-sse/services/compression/engines/types.ts b/open-sse/services/compression/engines/types.ts index 02cb601023..dcbca19b96 100644 --- a/open-sse/services/compression/engines/types.ts +++ b/open-sse/services/compression/engines/types.ts @@ -47,6 +47,11 @@ export interface CompressionEngine { targets: CompressionEngineTarget[]; stackable: boolean; stackPriority: number; + /** + * Marks an intentionally-lossy sampling engine (e.g. ionizer). The fidelity gate SKIPS such + * engines: their drop is deliberate and recoverable via CCR, not accidental corruption. + */ + sampling?: boolean; metadata: CompressionEngineMetadata; apply(body: Record, options?: CompressionEngineApplyOptions): CompressionResult; /** diff --git a/open-sse/services/compression/eval/fidelityCheck.ts b/open-sse/services/compression/eval/fidelityCheck.ts new file mode 100644 index 0000000000..cfed62af40 --- /dev/null +++ b/open-sse/services/compression/eval/fidelityCheck.ts @@ -0,0 +1,61 @@ +import { createCostMeter } from "./costMeter.ts"; +import { buildJudgePrompt, parseJudgeVerdict } from "./judge.ts"; +import type { JudgeVerdict, ModelClient } from "./types.ts"; + +export interface FidelityItem { + id: string; + original: string; + compressed: string; +} + +export interface FidelityVerdict { + id: string; + verdict: JudgeVerdict | null; + usdCost: number; + skippedCapped: boolean; +} + +export interface FidelityBatchResult { + results: FidelityVerdict[]; + totalUsd: number; + capped: boolean; +} + +/** + * Run a USD-capped fidelity judge over a batch of (original, compressed) pairs. + * + * Iterates items in order; stops adding new LLM calls once the accumulated cost + * exceeds `costCapUsd`. Remaining items receive `skippedCapped: true` and + * `verdict: null` so callers can distinguish "not judged" from "unparseable". + * + * Hard Rule #18: NOT unit-tested for real LLM calls; VPS-validated via the + * /api/compression/compare/verify route. + */ +export async function judgeFidelityBatch( + client: ModelClient, + judgeModel: string, + items: FidelityItem[], + costCapUsd: number +): Promise { + const meter = createCostMeter(costCapUsd); + const results: FidelityVerdict[] = []; + let capped = false; + + for (const item of items) { + if (capped || meter.exceeded) { + results.push({ id: item.id, verdict: null, usdCost: 0, skippedCapped: true }); + continue; + } + try { + const prompt = buildJudgePrompt(item.original, item.compressed); + const { text, usdCost } = await client.complete(judgeModel, prompt); + meter.add(usdCost ?? 0); + results.push({ id: item.id, verdict: parseJudgeVerdict(text), usdCost: usdCost ?? 0, skippedCapped: false }); + } catch { + results.push({ id: item.id, verdict: "unparseable", usdCost: 0, skippedCapped: false }); + } + if (meter.exceeded) capped = true; + } + + return { results, totalUsd: meter.spent, capped }; +} diff --git a/open-sse/services/compression/fidelityGate.ts b/open-sse/services/compression/fidelityGate.ts new file mode 100644 index 0000000000..9f58b2ea0a --- /dev/null +++ b/open-sse/services/compression/fidelityGate.ts @@ -0,0 +1,124 @@ +import { extractPreservedBlocks } from "./preservation.ts"; + +export interface FidelityGateConfig { + enabled: boolean; + /** % of input protected tokens that must survive. Default 95. */ + minTokenSurvivalPercent?: number; + /** % of input JSON keys that must survive. Default 90. */ + minJsonKeyPercent?: number; + /** Require every input number literal to appear in the output. Default true. */ + checkNumericIntegrity?: boolean; + /** Require every input @@…@@ hunk header to appear in the output. Default true. */ + checkDiffHunks?: boolean; +} + +export type FidelityInvariant = "protected-tokens" | "numeric" | "json-keys" | "diff-hunks"; + +export interface FidelityResult { + passed: boolean; + failedInvariant?: FidelityInvariant; + detail?: string; +} + +const CRITICAL_KINDS = new Set([ + "url", + "const_case", + "env_var", + "version", + "dotted_identifier", + "function_call", + "file_path", + "inline_code", +]); + +// Anti-ReDoS: all quantifiers are bounded. +// NUMERIC_RE matches a number plus any trailing run of digits/dots/commas (e.g. "1,3" in a diff +// hunk, "1," in JSON). This is intentional: comparing the same literal slice in input vs output +// keeps the check structural-aware and ReDoS-bounded ({0,40}). +const NUMERIC_RE = /\d[\d.,]{0,40}/g; +const JSON_KEY_RE = /"([A-Za-z_$][\w$-]{0,80})"\s*:/g; +const HUNK_RE = /@@ -\d{1,9}(?:,\d{1,9})? \+\d{1,9}(?:,\d{1,9})? @@/g; + +function survivalRatio(needles: string[], haystack: string): number { + if (needles.length === 0) return 1; + let survived = 0; + for (const n of needles) if (haystack.includes(n)) survived++; + return survived / needles.length; +} + +function uniq(values: Iterable): string[] { + return Array.from(new Set(values)); +} + +/** + * Deterministic per-step fidelity check. Returns {passed:false, failedInvariant, detail} + * on the FIRST failing invariant (cheap→expensive order), else {passed:true}. + * FAIL-OPEN: any internal error → {passed:true} (a verifier bug must never block compression). + */ +export function checkFidelity( + inputText: string, + outputText: string, + cfg: FidelityGateConfig +): FidelityResult { + try { + const tokens = uniq( + extractPreservedBlocks(inputText) + .blocks.filter((b) => CRITICAL_KINDS.has(b.kind)) + .map((b) => b.content.trim()) + .filter((c) => c.length > 0) + ); + const minTok = (cfg.minTokenSurvivalPercent ?? 95) / 100; + const tokRatio = survivalRatio(tokens, outputText); + if (tokRatio < minTok) { + return { + passed: false, + failedInvariant: "protected-tokens", + detail: `tokens protegidos ${Math.round(tokRatio * 100)}% < ${Math.round(minTok * 100)}%`, + }; + } + + if (cfg.checkDiffHunks !== false) { + for (const h of uniq(inputText.match(HUNK_RE) ?? [])) { + if (!outputText.includes(h)) { + return { + passed: false, + failedInvariant: "diff-hunks", + detail: `hunk "${h}" ausente no output`, + }; + } + } + } + + if (cfg.checkNumericIntegrity !== false) { + for (const num of uniq(inputText.match(NUMERIC_RE) ?? [])) { + if (!outputText.includes(num)) { + return { + passed: false, + failedInvariant: "numeric", + detail: `número "${num}" ausente no output`, + }; + } + } + } + + const keys = uniq(Array.from(inputText.matchAll(JSON_KEY_RE), (m) => m[1])); + if (keys.length > 0) { + const minKey = (cfg.minJsonKeyPercent ?? 90) / 100; + const keyRatio = survivalRatio( + keys.map((k) => `"${k}"`), + outputText + ); + if (keyRatio < minKey) { + return { + passed: false, + failedInvariant: "json-keys", + detail: `chaves JSON ${Math.round(keyRatio * 100)}% < ${Math.round(minKey * 100)}%`, + }; + } + } + + return { passed: true }; + } catch { + return { passed: true }; + } +} diff --git a/open-sse/services/compression/fidelityGateStep.ts b/open-sse/services/compression/fidelityGateStep.ts new file mode 100644 index 0000000000..cdec453b8a --- /dev/null +++ b/open-sse/services/compression/fidelityGateStep.ts @@ -0,0 +1,42 @@ +import { extractTextContent } from "./messageContent.ts"; +import { checkFidelity, type FidelityGateConfig } from "./fidelityGate.ts"; +import type { CompressionResult } from "./types.ts"; +import type { StackAccumulator } from "./strategySelector.ts"; +import { getCompressionEngine } from "./engines/registry.ts"; + +function bodyToText(body: Record): string { + const messages = body.messages; + if (!Array.isArray(messages)) return ""; + return messages.map((m) => extractTextContent((m as { content?: unknown }).content as never)).join("\n"); +} + +/** + * Fidelity gate (opt-in, independent of TV1). Called at each advance point AFTER mergeStackStep + * pushed the step's breakdown entry. Off → zero-cost `return true` (byte-identical legacy). On a + * fidelity failure it marks the just-pushed breakdown entry rejected (no advance) and returns false. + */ +export function gateAdvance( + result: CompressionResult, + inputBody: Record, + fidelityGate: FidelityGateConfig | undefined, + acc: StackAccumulator, + engineId?: string +): boolean { + if (!fidelityGate?.enabled) return true; + if (engineId && getCompressionEngine(engineId)?.sampling) return true; // lossy-by-design, CCR-recoverable + const verdict = checkFidelity(bodyToText(inputBody), bodyToText(result.body), fidelityGate); + if (verdict.passed) return true; + // mergeStackStep only pushed a breakdown entry when result.stats exists; only then is + // acc.breakdown's last entry THIS step's (else it would be the prior engine's — don't touch it). + if (result.stats) { + const last = acc.breakdown[acc.breakdown.length - 1]; + if (last) { + last.rejected = true; + last.rejectReason = verdict.detail ?? verdict.failedInvariant; + last.compressedTokens = last.originalTokens; + last.savingsPercent = 0; + } + } + acc.fallbackApplied = true; + return false; +} diff --git a/open-sse/services/compression/ruleLoader.ts b/open-sse/services/compression/ruleLoader.ts index 6bea11b030..b6a9df9471 100644 --- a/open-sse/services/compression/ruleLoader.ts +++ b/open-sse/services/compression/ruleLoader.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import os from "node:os"; import type { CavemanIntensity, CavemanRule } from "./types.ts"; type CavemanRuleCategory = NonNullable; @@ -58,14 +58,29 @@ function getRuleFlags(rule: FileRule): string { return rule.flags ?? "gi"; } +function getModuleDir(): string { + const anchors = [process.cwd()]; + const argv1 = process.argv[1]; + if (typeof argv1 === "string" && argv1) anchors.push(path.dirname(argv1)); + const rel = path.join("open-sse", "services", "compression"); + for (const anchor of anchors) { + let dir = path.resolve(anchor); + for (let i = 0; i <= 8; i++) { + if (fs.existsSync(path.join(dir, rel))) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + return path.join(os.homedir(), ".omniroute"); +} + function getRulesDir(): string { if (rulesDirCache) return rulesDirCache; - const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const root = getModuleDir(); const candidates = [ - path.join(moduleDir, "rules"), - path.join(moduleDir, "..", "services", "compression", "rules"), - path.join(process.cwd(), "open-sse", "services", "compression", "rules"), - path.join(process.cwd(), "app", "open-sse", "services", "compression", "rules"), + path.join(root, "open-sse", "services", "compression", "rules"), + path.join(root, "app", "open-sse", "services", "compression", "rules"), ]; rulesDirCache = candidates.find((candidate, index) => { diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index 264d8ff086..03dbcb2227 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -5,6 +5,8 @@ import type { CompressionResult, CompressionStats, } from "./types.ts"; +import { type FidelityGateConfig } from "./fidelityGate.ts"; +import { gateAdvance } from "./fidelityGateStep.ts"; import type { CompressionEngineApplyOptions } from "./engines/types.ts"; import { applyLiteCompression } from "./lite.ts"; import { cavemanCompress } from "./caveman.ts"; @@ -552,6 +554,8 @@ interface StackOptions { compressionComboId?: string | null; /** TV1 bail-out discipline (opt-in, default disabled). */ bailout?: BailoutConfig; + /** Opt-in per-step fidelity gate (default disabled). */ + fidelityGate?: FidelityGateConfig; /** Authenticated principal id — threaded through to CCR engine for store scoping. */ principalId?: string; /** F3.3: called once per engine as it completes (live per-engine streaming). */ @@ -581,7 +585,7 @@ function reportEngineStep( } /** Accumulates per-step telemetry across a stacked run (shared sync/async). */ -interface StackAccumulator { +export interface StackAccumulator { techniques: Set; rules: Set; breakdown: NonNullable; @@ -719,6 +723,7 @@ export function applyStackedCompression( const start = performance.now(); const bailout = options?.bailout; + const fidelityGate = options?.fidelityGate ?? options?.config?.fidelityGate; const onStep = options?.onEngineStep; const totalSteps = steps.length; let stepIdx = 0; @@ -746,14 +751,14 @@ export function applyStackedCompression( continue; } mergeStackStep(acc, step.engine, result); - if (decideStep(result, bailout).advance) { + if (decideStep(result, bailout).advance && gateAdvance(result, currentBody, fidelityGate, acc, step.engine)) { currentBody = result.body; compressed = true; } } else { const result = engine.apply(currentBody, buildStepOptions(step, options)); mergeStackStep(acc, step.engine, result); - if (result.compressed) { + if (result.compressed && gateAdvance(result, currentBody, fidelityGate, acc, step.engine)) { currentBody = result.body; compressed = true; } @@ -791,6 +796,7 @@ export async function applyStackedCompressionAsync( const start = performance.now(); const bailout = options?.bailout; + const fidelityGate = options?.fidelityGate ?? options?.config?.fidelityGate; const onStep = options?.onEngineStep; const totalSteps = steps.length; let stepIdx = 0; @@ -819,7 +825,7 @@ export async function applyStackedCompressionAsync( continue; } mergeStackStep(acc, step.engine, result); - if (decideStep(result, bailout).advance) { + if (decideStep(result, bailout).advance && gateAdvance(result, currentBody, fidelityGate, acc, step.engine)) { currentBody = result.body; compressed = true; } @@ -828,7 +834,7 @@ export async function applyStackedCompressionAsync( ? await engine.applyAsync(currentBody, stepOptions) : engine.apply(currentBody, stepOptions); mergeStackStep(acc, step.engine, result); - if (result.compressed) { + if (result.compressed && gateAdvance(result, currentBody, fidelityGate, acc, step.engine)) { currentBody = result.body; compressed = true; } diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index 3331aa6f69..0dd2c73cbd 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -11,6 +11,7 @@ import { ENGINE_IDS } from "./engineCatalog.ts"; import type { ContextBudgetConfig } from "./adaptiveCompression/types.ts"; +import type { FidelityGateConfig } from "./fidelityGate.ts"; // Re-export so consumers that already import from this module (e.g. src/lib/db/compression.ts) // can get ENGINE_IDS without a second bare `@omniroute/open-sse/...engineCatalog.ts` specifier. @@ -141,6 +142,8 @@ export interface CompressionConfig { comboOverrides: Record; compressionComboId?: string | null; stackedPipeline?: CompressionPipelineStep[]; + /** Opt-in per-step fidelity gate (default disabled). */ + fidelityGate?: FidelityGateConfig; cavemanConfig?: CavemanConfig; cavemanOutputMode?: CavemanOutputModeConfig; /** Phase 4A: selected output styles (supersedes cavemanOutputMode via a back-compat shim). */ @@ -232,6 +235,8 @@ export interface CompressionStats { techniquesUsed: string[]; rulesApplied?: string[]; durationMs?: number; + rejected?: boolean; + rejectReason?: string; }>; } diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index b5bf0780b8..515765a2f2 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -95,6 +95,15 @@ export const DEFAULT_SAFETY_SETTINGS = [ { category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "OFF" }, ]; +function normalizeAudioMimeType(format: unknown): string { + const normalized = + typeof format === "string" && format.trim() ? format.trim().toLowerCase() : "wav"; + if (normalized === "mp3") { + return "audio/mpeg"; + } + return `audio/${normalized}`; +} + // Convert OpenAI content to Gemini parts export function convertOpenAIContentToParts(content: unknown): JsonRecord[] { const parts: JsonRecord[] = []; @@ -107,19 +116,11 @@ export function convertOpenAIContentToParts(content: unknown): JsonRecord[] { if (rec.type === "text") { parts.push({ text: rec.text }); } else if (rec.type === "input_audio" || rec.type === "audio") { - // OpenAI Chat Completions audio input shape (ports decolua/9router#912 + #913): - // { type:"input_audio", input_audio:{data,format} } — some clients use the - // { type:"audio", audio:{data,format} } shape — -> Gemini - // `inlineData: { mimeType: "audio/", data }`. mp3 normalizes to the - // canonical `audio/mpeg`; a leading `data:;base64,` prefix is stripped so - // Gemini receives raw base64. const audio = toRecord(rec.input_audio || rec.audio); if (typeof audio.data === "string" && audio.data) { - const format = typeof audio.format === "string" && audio.format ? audio.format : "wav"; - const mimeType = format === "mp3" ? "audio/mpeg" : `audio/${format}`; parts.push({ inlineData: { - mimeType, + mimeType: normalizeAudioMimeType(audio.format), data: audio.data.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""), }, }); diff --git a/open-sse/translator/helpers/openaiHelper.ts b/open-sse/translator/helpers/openaiHelper.ts index b6a9bbfb32..5c2a9765f4 100644 --- a/open-sse/translator/helpers/openaiHelper.ts +++ b/open-sse/translator/helpers/openaiHelper.ts @@ -31,7 +31,17 @@ const CLAUDE_TOOL_CHOICE_REQUIRED = "an" + "y"; // Filter messages to OpenAI standard format // Remove: redacted_thinking, and other non-OpenAI blocks // Convert: thinking blocks → reasoning_content on the message -export function filterToOpenAIFormat(body) { +export function filterToOpenAIFormat(body, opts = {}) { + // #2069 — when the routed provider honors OpenAI-format cache_control + // breakpoints (DashScope/alibaba, Xiaomi MiMo, etc.) and preservation was + // requested upstream, keep the `cache_control` field on each content block + // instead of destructuring it away. `signature` is always stripped. + const preserveCacheControl = opts?.preserveCacheControl === true; + // #4849 strips reasoning_content from tool-call assistant turns to stop O(n^2) + // context growth — but reasoning-replay providers (DeepSeek V4, Kimi K2, etc.) + // REQUIRE the client's reasoning_content to be passed back, so keep it for them + // (the caller sets this when the routed model needs reasoning replay). + const preserveReasoningContent = opts?.preserveReasoningContent === true; if (!body.messages || !Array.isArray(body.messages)) return body; body.messages = body.messages.map((msg) => { @@ -45,8 +55,10 @@ export function filterToOpenAIFormat(body) { // Keep assistant messages with tool_calls, but strip reasoning_content — // reasoning blobs inflate context on every subsequent agentic turn (O(n^2)). + // Exception: reasoning-replay providers must keep client-provided + // reasoning_content (they 400 without it), so preserve it when requested. if (msg.role === "assistant" && msg.tool_calls) { - if (msg.reasoning_content !== undefined) { + if (!preserveReasoningContent && msg.reasoning_content !== undefined) { const { reasoning_content, ...cleanMsg } = msg; return cleanMsg; } @@ -72,8 +84,13 @@ export function filterToOpenAIFormat(body) { // Only keep valid OpenAI content types if (VALID_OPENAI_CONTENT_TYPES.includes(block.type)) { - // Remove signature and cache_control fields - const { signature, cache_control, ...cleanBlock } = block; + // Strip `signature` always; strip `cache_control` unless the provider + // honors OpenAI-format cache breakpoints and preservation was requested (#2069). + const { signature, cache_control, ...rest } = block; + const cleanBlock = + preserveCacheControl && cache_control !== undefined + ? { ...rest, cache_control } + : rest; if ( cleanBlock.type === "text" && typeof cleanBlock.text === "string" && diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index d0f00967aa..16b6fdf1a3 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -5,6 +5,7 @@ import { prepareClaudeRequest, } from "./helpers/claudeHelper.ts"; import { filterToOpenAIFormat } from "./helpers/openaiHelper.ts"; +import { providerHonorsOpenAIFormatCacheControl } from "../utils/cacheControlPolicy.ts"; import { coerceToolSchemas, injectEmptyReasoningContentForToolCalls, @@ -213,12 +214,22 @@ export function translateRequest( if (toOpenAI) { // Forward Copilot UA marker to source→openai translators only. const hasTargetHint = targetFormat != null; + // #2069 — forward the cache_control-preservation intent so the + // source→openai translator (e.g. claudeToOpenAIRequest) keeps the + // client's breakpoints — but ONLY for providers that honor explicit + // OpenAI-format cache_control (DashScope/alibaba, Xiaomi MiMo). Generic + // / implicit-cache OpenAI providers (openai/codex/azure) must still be + // stripped. + const preserveCacheControl = + options?.preserveCacheControl === true && + providerHonorsOpenAIFormatCacheControl(provider); const step1Credentials = - options?.copilotClient || hasTargetHint + options?.copilotClient || hasTargetHint || preserveCacheControl ? { ...(credentials && typeof credentials === "object" ? credentials : {}), ...(options?.copilotClient ? { _copilotClient: true } : {}), ...(hasTargetHint ? { _targetFormat: targetFormat } : {}), + ...(preserveCacheControl ? { _preserveCacheControl: true } : {}), } : credentials; result = toOpenAI(model, result, stream, step1Credentials); @@ -253,10 +264,36 @@ export function translateRequest( } } + // Resolve reasoning-replay status up-front: it gates both the reasoning_content + // strip in filterToOpenAIFormat below (#4849 must NOT strip client reasoning for + // replay providers) and the cache re-injection further down. + const normalizedProvider = String(provider ?? ""); + const normalizedModel = String(model ?? ""); + const resolvedCapabilities = getResolvedModelCapabilities({ + provider: normalizedProvider, + model: normalizedModel, + }); + const isReasoner = requiresReasoningReplay({ + provider: normalizedProvider, + model: normalizedModel, + thinkingEnabled: hasThinkingConfig(result), + supportsReasoning: supportsReasoning({ provider: normalizedProvider, model: normalizedModel }), + interleavedField: resolvedCapabilities?.interleavedField ?? null, + }); + // Always normalize to clean OpenAI format when target is OpenAI // This handles hybrid requests (e.g., OpenAI messages + Claude tools) if (targetFormat === FORMATS.OPENAI) { - result = filterToOpenAIFormat(result); + // #2069 — preserve client cache_control breakpoints only for providers that + // honor explicit OpenAI-format markers (DashScope/alibaba, Xiaomi MiMo) when + // requested upstream; generic/implicit-cache OpenAI providers stay stripped. + result = filterToOpenAIFormat(result, { + preserveCacheControl: + options?.preserveCacheControl === true && + providerHonorsOpenAIFormatCacheControl(provider), + // #4849 regression guard: keep client reasoning_content for replay providers. + preserveReasoningContent: isReasoner, + }); } // Final step: prepare request for Claude format endpoints @@ -315,19 +352,9 @@ export function translateRequest( // clients omit it from the conversation history. Without this, DeepSeek V4 // returns 400: "The reasoning_content in the thinking mode must be passed // back to the API." - const normalizedProvider = String(provider ?? ""); - const normalizedModel = String(model ?? ""); - const resolvedCapabilities = getResolvedModelCapabilities({ - provider: normalizedProvider, - model: normalizedModel, - }); - const isReasoner = requiresReasoningReplay({ - provider: normalizedProvider, - model: normalizedModel, - thinkingEnabled: hasThinkingConfig(result), - supportsReasoning: supportsReasoning({ provider: normalizedProvider, model: normalizedModel }), - interleavedField: resolvedCapabilities?.interleavedField ?? null, - }); + // isReasoner / normalizedProvider / normalizedModel / resolvedCapabilities were + // resolved up-front (before the OpenAI-format filter) so the #4849 reasoning strip + // could honor reasoning-replay providers. if (isReasoner && result.messages && Array.isArray(result.messages)) { const canReplayReasoningOnly = isReasoningOnlyReplayTarget(normalizedProvider, normalizedModel); diff --git a/open-sse/translator/request/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index 94b2a19b92..e96bf4b7e4 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -91,6 +91,16 @@ function shouldUseNativeResponsesWebSearch(credentials: unknown): boolean { // Convert Claude request to OpenAI format export function claudeToOpenAIRequest(model, body, stream, credentials: unknown = null) { + // #2069 — when the routed provider honors OpenAI-format cache_control breakpoints + // (DashScope/alibaba, etc.) and the upstream caller requested preservation, keep + // the client's cache_control markers on system + message text blocks instead of + // collapsing them away during the Claude→OpenAI conversion. + const preserveCacheControl = + credentials !== null && + typeof credentials === "object" && + !Array.isArray(credentials) && + (credentials as JsonRecord)._preserveCacheControl === true; + const result: { model: string; messages: JsonRecord[]; @@ -120,14 +130,35 @@ export function claudeToOpenAIRequest(model, body, stream, credentials: unknown // System message if (body.system) { - const systemContent = Array.isArray(body.system) - ? body.system - .map((s) => stripAnthropicBillingHeader(s.text || "")) - .filter(Boolean) - .join("\n") - : stripAnthropicBillingHeader(body.system); + // When preserving cache_control for a caching-capable provider, and the client + // tagged any system block, keep the array-of-blocks shape so the breakpoint + // survives (DashScope reads cache_control off content blocks). Otherwise fall + // back to the joined-string form expected by generic OpenAI providers (#2069). + const systemHasCacheControl = + preserveCacheControl && + Array.isArray(body.system) && + body.system.some((s) => s && typeof s === "object" && s.cache_control !== undefined); - if (systemContent) { + const systemContent = systemHasCacheControl + ? body.system.map((s) => { + // body.system may be a mixed array — handle string elements (and + // null/non-object) defensively so we never drop text or throw. + if (typeof s === "string") return { type: "text", text: stripAnthropicBillingHeader(s) }; + const rawText = s && typeof s === "object" ? (s as JsonRecord).text || "" : ""; + const block: JsonRecord = { type: "text", text: stripAnthropicBillingHeader(rawText) }; + if (s && typeof s === "object" && (s as JsonRecord).cache_control !== undefined) { + block.cache_control = (s as JsonRecord).cache_control; + } + return block; + }) + : Array.isArray(body.system) + ? body.system + .map((s) => stripAnthropicBillingHeader(s.text || "")) + .filter(Boolean) + .join("\n") + : stripAnthropicBillingHeader(body.system); + + if (systemHasCacheControl || systemContent) { result.messages.push({ role: "system", content: systemContent, @@ -139,7 +170,7 @@ export function claudeToOpenAIRequest(model, body, stream, credentials: unknown if (body.messages && Array.isArray(body.messages)) { for (let i = 0; i < body.messages.length; i++) { const msg = body.messages[i]; - const converted = convertClaudeMessage(msg); + const converted = convertClaudeMessage(msg, preserveCacheControl); if (converted) { // Handle array of messages (multiple tool results) if (Array.isArray(converted)) { @@ -317,7 +348,7 @@ function fixMissingToolResponses(messages) { } // Convert single Claude message - returns single message or array of messages -function convertClaudeMessage(msg) { +function convertClaudeMessage(msg, preserveCacheControl = false) { const role = msg.role === "user" || msg.role === "tool" ? "user" : "assistant"; // Simple string content @@ -334,9 +365,16 @@ function convertClaudeMessage(msg) { for (const block of msg.content) { switch (block.type) { - case "text": - parts.push({ type: "text", text: block.text }); + case "text": { + const textPart: JsonRecord = { type: "text", text: block.text }; + // #2069 — carry the client's cache_control breakpoint through to + // caching-capable OpenAI-format providers (DashScope/alibaba, etc.). + if (preserveCacheControl && block.cache_control !== undefined) { + textPart.cache_control = block.cache_control; + } + parts.push(textPart); break; + } case "image": if (block.source?.type === "base64") { @@ -382,11 +420,29 @@ function convertClaudeMessage(msg) { if (typeof block.content === "string") { resultContent = block.content; } else if (Array.isArray(block.content)) { + // Keep text in the tool message; lift any images out as a following user + // turn (OpenAI `tool` messages can't carry images). Without this, an + // image-only tool_result is JSON.stringify'd → base64 as text, which + // causes "input exceeds the context window" errors in OpenAI-protocol + // upstreams (port of decolua/9router#2123 by alican532). + const textParts: string[] = []; + let hasImage = false; + for (const c of block.content) { + if (c.type === "text") { + textParts.push(c.text); + } else if (c.type === "image" && c.source?.type === "base64") { + parts.push({ + type: "image_url", + image_url: { + url: `data:${c.source.media_type};base64,${c.source.data}`, + }, + }); + hasImage = true; + } + } resultContent = - block.content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n") || JSON.stringify(block.content); + textParts.join("\n") || + (hasImage ? "[tool returned an image; see attached]" : JSON.stringify(block.content)); } else if (block.content) { resultContent = JSON.stringify(block.content); } diff --git a/open-sse/utils/aiSdkCompat.ts b/open-sse/utils/aiSdkCompat.ts index 5d84d1b52e..088e898b82 100644 --- a/open-sse/utils/aiSdkCompat.ts +++ b/open-sse/utils/aiSdkCompat.ts @@ -7,6 +7,13 @@ export type StreamDefaultMode = "legacy" | "json"; export interface ResolveStreamFlagOptions { userAgent?: unknown; streamDefaultMode?: unknown; + /** + * When true, the provider rejects non-streaming requests (e.g. forceStream providers + * such as CodeBuddy). resolveStreamFlag will keep streaming even when the client sends + * Accept: application/json or stream:false; the caller is responsible for accumulating + * the stream and converting it to a JSON response for the client. (#2081) + */ + providerRequiresStreaming?: boolean; } function normalizeResolveStreamFlagOptions(optionsOrUserAgent?: unknown): ResolveStreamFlagOptions { @@ -35,7 +42,9 @@ export function clientWantsJsonResponse(acceptHeader: unknown): boolean { /** * Resolves stream behavior from request body + Accept header. - * Priority: explicit `stream: true/false` in body wins. + * Priority: explicit `stream: true/false` in body wins, UNLESS the provider + * requires streaming (`providerRequiresStreaming: true`) — in that case the + * result is always `true` regardless of client preference (#2081). * Accept header only acts as fallback when stream is not explicitly set. * Fixes #656: clients sending both `stream: true` and `Accept: application/json` * should still get streaming responses — body intent takes precedence. @@ -53,11 +62,18 @@ export function resolveStreamFlag( sourceFormat?: string, optionsOrUserAgent?: unknown ): boolean { - // Explicit body value always wins + const options = normalizeResolveStreamFlagOptions(optionsOrUserAgent); + + // Stream-only providers must keep streaming even when the client asked for JSON; + // OmniRoute accumulates the provider stream and converts it to JSON for the client + // downstream (handleForcedSSEToJson). Sending stream:false to such a provider + // returns HTTP 400. (#2081) + if (options.providerRequiresStreaming) return true; + + // Explicit body value always wins (for non-stream-only providers) if (bodyStream === true) return true; if (bodyStream === false) return false; - const options = normalizeResolveStreamFlagOptions(optionsOrUserAgent); const streamDefaultMode = normalizeStreamDefaultMode(options.streamDefaultMode); const acceptsEventStream = diff --git a/open-sse/utils/cacheControlPolicy.ts b/open-sse/utils/cacheControlPolicy.ts index 3603ce7a8f..e886bd3e63 100644 --- a/open-sse/utils/cacheControlPolicy.ts +++ b/open-sse/utils/cacheControlPolicy.ts @@ -92,8 +92,49 @@ const CACHING_PROVIDERS = new Set([ "openai", "codex", "azure", + // #2069 — Alibaba DashScope's OpenAI-compatible endpoints (alibaba / + // alibaba-cn, upstream "alicode"/"alicode-intl") natively honor + // `cache_control: {type:"ephemeral"}` breakpoints. Without these entries + // shouldPreserveCacheControl() returns false for Claude Code clients and the + // OpenAI-format translator strips cache_control, so DashScope never sees the + // hints and every request is a cache miss. + "alibaba", + "alibaba-cn", ]); +/** + * Providers that honor EXPLICIT `cache_control` breakpoints carried inside an + * OpenAI-format request body (i.e. the markers must be passed THROUGH the + * Claude→OpenAI translation instead of stripped). + * + * This is a strict subset of CACHING_PROVIDERS and deliberately excludes + * `openai` / `codex` / `azure`: those use AUTOMATIC prefix caching (#3955) and + * do NOT accept explicit `cache_control` fields in the request — forwarding the + * markers there is meaningless at best and a 400 "unknown field" at worst, and + * it broke the chatCore "strips cache markers for non-Claude providers" test. + * Claude-format providers re-inject markers via prepareClaudeRequest, so they + * are not listed here either. + */ +const OPENAI_FORMAT_CACHE_CONTROL_PROVIDERS = new Set([ + // #2069 — DashScope OpenAI-compatible endpoints accept ephemeral breakpoints. + "alibaba", + "alibaba-cn", + // #3088 — Xiaomi MiMo honors OpenAI-format cache_control breakpoints. + "xiaomi-mimo", +]); + +/** + * Whether `cache_control` markers should be PASSED THROUGH the OpenAI-format + * translation for this provider (vs. stripped). Used to gate the request-side + * passthrough so generic / implicit-cache OpenAI providers keep getting cleaned. + */ +export function providerHonorsOpenAIFormatCacheControl( + provider: string | null | undefined +): boolean { + if (!provider) return false; + return OPENAI_FORMAT_CACHE_CONTROL_PROVIDERS.has(provider.toLowerCase()); +} + /** * Detect if the client is Claude Code or another caching-aware client */ diff --git a/open-sse/utils/composerToolCalls.ts b/open-sse/utils/composerToolCalls.ts new file mode 100644 index 0000000000..d688973ce3 --- /dev/null +++ b/open-sse/utils/composerToolCalls.ts @@ -0,0 +1,307 @@ +/** + * Parser for Cursor Composer's DeepSeek-style inline tool call format. + * + * Composer (Cursor's `cu/composer-2.5*` models) emits tool calls inside its + * normal text output using sentinel markers, e.g.: + * + * Optional preamble text... + * <|tool▁calls▁begin|> + * <|tool▁call▁begin|> + * tool_name + * <|tool▁sep|>arg_name + * arg_value + * <|tool▁sep|>arg_name_2 + * arg_value_2 + * <|tool▁call▁end|> + * <|tool▁call▁begin|> + * other_tool + * <|tool▁sep|>arg + * value + * <|tool▁call▁end|> + * <|tool▁calls▁end|> + * Optional trailing text... + * + * Markers use full-width pipes (`|`, U+FF5C) and small-triangle separator + * (`▁`, U+2581). We also accept ASCII fallbacks (`<|tool_calls_begin|>` etc.) + * defensively in case Cursor ever changes encoding. + * + * The parser converts this into the OpenAI Chat Completions `tool_calls` + * shape and returns the residual content (preamble + trailing text) so the + * caller can decide whether to surface it as the assistant's visible message. + */ + +const FW = "[||]"; // full-width or ASCII pipe +const SEP = "[▁_]"; // full-width separator or ASCII underscore + +// Match the outer tool-calls block, lazily. +const OUTER_RE = new RegExp( + `<${FW}tool${SEP}calls${SEP}begin${FW}>([\\s\\S]*?)<${FW}tool${SEP}calls${SEP}end${FW}>`, + "i" +); + +// Match a single tool-call block. +const INNER_RE = new RegExp( + `<${FW}tool${SEP}call${SEP}begin${FW}>([\\s\\S]*?)<${FW}tool${SEP}call${SEP}end${FW}>`, + "gi" +); + +// Match an arg separator. +const ARG_SEP_RE = new RegExp(`<${FW}tool${SEP}sep${FW}>`, "gi"); + +// Heuristic: any partial opening marker (start of `<|tool` ... without the +// final `>`). Used by the streaming parser to know it must hold back text. +const PARTIAL_OPEN_MARKER_RE = new RegExp( + `<${FW}?(?:t(?:o(?:o(?:l(?:${SEP}(?:c(?:a(?:l(?:l(?:s)?(?:${SEP}(?:b(?:e(?:g(?:i(?:n${FW}?>?)?)?)?)?)?)?)?)?)?)?)?)?)?)?)?$`, + "i" +); + +export interface ComposerToolCall { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +} + +export interface ParseComposerResult { + content: string; + toolCalls: ComposerToolCall[]; +} + +export interface StreamingState { + emitted: number; + done: boolean; +} + +export interface FeedChunkResult { + safeDelta: string; + ready: boolean; + toolCalls: ComposerToolCall[]; + holdback: boolean; +} + +// Detection helper +export function hasComposerToolCalls(text: string): boolean { + if (!text || typeof text !== "string") return false; + return OUTER_RE.test(text); +} + +function generateToolCallId(index: number): string { + // Format: call_; mirrors what OpenAI clients expect. + // Use crypto for deterministic-quality randomness (Hard Rule: no Math.random for IDs). + const rand = crypto.randomUUID().replace(/-/g, "").slice(0, 10); + return `call_${rand}${index}`; +} + +/** + * Parse a single inner tool-call block body (the text between + * `<|tool▁call▁begin|>` and `<|tool▁call▁end|>`). + * + * Body shape: + * tool_name + * <|tool▁sep|>arg_name + * arg_value + * <|tool▁sep|>arg_name_2 + * arg_value_2 + * + * Returns {name, arguments} where arguments is a JSON string suitable for + * the OpenAI tool_calls schema. Arg values are taken verbatim from the + * text between one separator's argname-line and the next separator (or + * end of body). We attempt to detect when a value is already valid JSON + * (object/array/number/bool/null) and store it natively; otherwise we keep + * it as a string. + */ +function parseInnerCall(body: string): { name: string; arguments: string } | null { + // Body starts with the tool name on (typically) its own line, optionally + // surrounded by whitespace, then the first `<|tool▁sep|>`. + const trimmed = body.replace(/^\s+|\s+$/g, ""); + // Split by argument separator first to isolate name + arg blocks. + const segments = trimmed.split(ARG_SEP_RE); + // First segment is the tool name (and any preamble whitespace). + const name = (segments.shift() ?? "").trim(); + if (!name) { + return null; + } + const args: Record = {}; + for (const seg of segments) { + if (!seg) continue; + // Each segment is `arg_name\nvalue\n...`. The arg name is the first + // line; everything after the first newline is the value (verbatim, + // including additional newlines). + const idxNl = seg.indexOf("\n"); + let argName: string; + let argValue: string; + if (idxNl < 0) { + argName = seg.trim(); + argValue = ""; + } else { + argName = seg.slice(0, idxNl).trim(); + argValue = seg.slice(idxNl + 1); + } + if (!argName) continue; + // Strip the trailing newline before the next separator (the separator + // marker itself was already consumed by the split). + argValue = argValue.replace(/\n+$/, ""); + // Attempt JSON parse so structured args (objects/arrays/numbers/bools) + // come through as native JSON values rather than quoted strings. + args[argName] = coerceArgValue(argValue); + } + return { name, arguments: JSON.stringify(args) }; +} + +function coerceArgValue(raw: string): unknown { + if (raw === "") return ""; + const stripped = raw.trim(); + if ( + (stripped.startsWith("{") && stripped.endsWith("}")) || + (stripped.startsWith("[") && stripped.endsWith("]")) + ) { + try { + return JSON.parse(stripped); + } catch { + // not valid JSON — fall through to string + } + } + if (stripped === "true") return true; + if (stripped === "false") return false; + if (stripped === "null") return null; + if (/^-?\d+$/.test(stripped)) { + const n = Number(stripped); + if (Number.isSafeInteger(n)) return n; + } + if (/^-?\d*\.\d+$/.test(stripped)) { + const n = Number(stripped); + if (Number.isFinite(n)) return n; + } + return raw; +} + +/** + * Parse a complete (non-streaming) Composer content string. + * + * Returns { content, toolCalls } where: + * - content: the residual visible text (preamble + trailing text combined + * and trimmed; empty string if nothing left). + * - toolCalls: array of OpenAI-shaped tool_calls; empty if none found. + * + * If the input has no tool-call block, returns { content: input, toolCalls: [] }. + */ +export function parseComposerToolCalls(text: string): ParseComposerResult { + if (!text || typeof text !== "string") { + return { content: text || "", toolCalls: [] }; + } + + const match = text.match(OUTER_RE); + if (!match || match.index === undefined) { + return { content: text, toolCalls: [] }; + } + + const preamble = text.slice(0, match.index); + const trailing = text.slice(match.index + match[0].length); + const block = match[1]; + + const toolCalls: ComposerToolCall[] = []; + let idx = 0; + for (const innerMatch of block.matchAll(INNER_RE)) { + const parsed = parseInnerCall(innerMatch[1]); + if (!parsed) continue; + toolCalls.push({ + id: generateToolCallId(idx), + type: "function", + function: parsed, + }); + idx += 1; + } + + const residual = (preamble + trailing).trim(); + return { content: residual, toolCalls }; +} + +/** + * Streaming helper: feed it the *accumulated* content seen so far and it + * returns what is safe to emit as visible text, plus whether tool calls + * are now ready to be flushed. + * + * { safeDelta, ready, toolCalls, holdback } + * + * safeDelta: text delta that can be emitted to the client right now as a + * content delta (relative to how much was already emitted via state.emitted). + * ready: true once a complete `<|tool▁calls▁end|>` has been seen and + * toolCalls are parsed. + * toolCalls: parsed tool calls (only populated when ready=true). + * holdback: whether more bytes are being held back (an outer-block has + * opened but not yet closed, OR a partial opening marker is at the + * tail of the buffer). + * + * Usage pattern: + * const state = createStreamingState(); + * for each frame: const out = feedStreamingChunk(state, accumulated); + * emit out.safeDelta as content delta; + * if (out.ready) emit out.toolCalls and stop emitting content. + */ +export function createStreamingState(): StreamingState { + return { + emitted: 0, // number of safe content chars already emitted + done: false, + }; +} + +export function feedStreamingChunk(state: StreamingState, accumulated: string): FeedChunkResult { + if (state.done) { + return { safeDelta: "", ready: false, toolCalls: [], holdback: false }; + } + if (!accumulated) { + return { safeDelta: "", ready: false, toolCalls: [], holdback: false }; + } + + // 1. Complete block already in buffer? Parse it. + const m = accumulated.match(OUTER_RE); + if (m && m.index !== undefined) { + const preamble = accumulated.slice(0, m.index); + const block = m[1]; + const toolCalls: ComposerToolCall[] = []; + let idx = 0; + for (const innerMatch of block.matchAll(INNER_RE)) { + const parsed = parseInnerCall(innerMatch[1]); + if (!parsed) continue; + toolCalls.push({ + id: generateToolCallId(idx), + type: "function", + function: parsed, + }); + idx += 1; + } + // Emit any preamble we haven't emitted yet. + const safe = preamble; + const safeDelta = safe.length > state.emitted ? safe.slice(state.emitted) : ""; + state.emitted = safe.length; + state.done = true; + return { safeDelta, ready: true, toolCalls, holdback: false }; + } + + // 2. Look for an opening-only marker. If found, everything before it is + // safe; everything after must be held until we see the closing marker. + const openOnlyRe = new RegExp(`<${FW}tool${SEP}calls${SEP}begin${FW}>`, "i"); + const openMatch = accumulated.match(openOnlyRe); + if (openMatch && openMatch.index !== undefined) { + const safe = accumulated.slice(0, openMatch.index); + const safeDelta = safe.length > state.emitted ? safe.slice(state.emitted) : ""; + state.emitted = safe.length; + return { safeDelta, ready: false, toolCalls: [], holdback: true }; + } + + // 3. Partial opening marker at the tail? Hold back the suspicious tail. + const tailMatch = accumulated.match(PARTIAL_OPEN_MARKER_RE); + if (tailMatch && tailMatch.index !== undefined) { + const safe = accumulated.slice(0, tailMatch.index); + const safeDelta = safe.length > state.emitted ? safe.slice(state.emitted) : ""; + state.emitted = safe.length; + return { safeDelta, ready: false, toolCalls: [], holdback: true }; + } + + // 4. No markers anywhere. Emit everything new. + const safeDelta = accumulated.length > state.emitted ? accumulated.slice(state.emitted) : ""; + state.emitted = accumulated.length; + return { safeDelta, ready: false, toolCalls: [], holdback: false }; +} diff --git a/open-sse/utils/diagnostics.ts b/open-sse/utils/diagnostics.ts index 8c58358c80..b323da4496 100644 --- a/open-sse/utils/diagnostics.ts +++ b/open-sse/utils/diagnostics.ts @@ -200,6 +200,42 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null return null; } + // ── Claude / Anthropic Messages shape ── + // A `/v1/messages` request to a Claude provider keeps the response in Claude shape + // (no translation when client and provider formats both = Claude), so it reaches here + // as `{ type:"message", content:[…] }` — which has neither `object:"response"` nor + // `choices`. Without this branch every non-streaming Claude response (incl. plain text) + // falls through to `empty_choices` → a false 502 (#5108, regression from #4942). + if (body.type === "message" && Array.isArray(body.content)) { + const hasOutput = (body.content as unknown[]).some((block) => { + // A malformed/partial provider response could carry a null (or non-object) + // entry in `content`; guard before type-asserting so the detector never + // throws on `null.type` (that would crash the whole non-stream classifier). + if (block === null || typeof block !== "object") return false; + const b = block as Record; + // Text block with visible text. + if (b.type === "text" && typeof b.text === "string" && (b.text as string).length > 0) { + return true; + } + // Extended-thinking block: a non-empty `signature` is cryptographic proof the + // thinking step ran, so it is a valid completion even when the thinking text is "". + if ( + b.type === "thinking" && + typeof b.signature === "string" && + (b.signature as string).length > 0 + ) { + return true; + } + // Redacted thinking and tool_use are valid structural output. + if (b.type === "redacted_thinking") return true; + if (b.type === "tool_use" && typeof b.id === "string" && (b.id as string).length > 0) { + return true; + } + return false; + }); + return hasOutput ? null : "empty_choices"; + } + // ── Chat Completions shape ── const choices = body.choices; if (!Array.isArray(choices) || choices.length === 0) return "empty_choices"; diff --git a/open-sse/utils/earlyStreamKeepalive.ts b/open-sse/utils/earlyStreamKeepalive.ts index e83a3d645f..db2b98f194 100644 --- a/open-sse/utils/earlyStreamKeepalive.ts +++ b/open-sse/utils/earlyStreamKeepalive.ts @@ -28,6 +28,12 @@ const ENCODER = new TextEncoder(); const KEEPALIVE_FRAME = ENCODER.encode(": omniroute-keepalive\n\n"); +// Anthropic Messages-format keepalive: a REAL `ping` SSE event, not a comment. +// Anthropic clients (Claude Code, the Anthropic SDK) reset their stream/first-token +// watchdog on real SSE events but ignore SSE comments (`: ...`), so on a slow first +// token the comment frame lets the client abort and retry the stream. Anthropic's own +// API emits `event: ping` for exactly this reason; the /v1/messages route mirrors it. +export const ANTHROPIC_PING_FRAME = ENCODER.encode('event: ping\ndata: {"type":"ping"}\n\n'); const ERROR_FRAME = ENCODER.encode( `event: error\ndata: ${JSON.stringify({ error: { message: "Upstream stream failed before completion.", type: "stream_error" }, @@ -41,6 +47,13 @@ export type EarlyStreamKeepaliveOptions = { intervalMs?: number; /** Client request signal — propagated so a client disconnect cancels the upstream read. */ signal?: AbortSignal | null; + /** + * Frame emitted on each keepalive tick. Defaults to an SSE comment + * (`: omniroute-keepalive`). Anthropic-format routes (/v1/messages) must pass + * `ANTHROPIC_PING_FRAME` instead, because Anthropic clients ignore SSE comments + * for their stream watchdog and only a real `event: ping` keeps them from aborting. + */ + keepaliveFrame?: Uint8Array; }; type SettledHandler = { ok: true; response: Response } | { ok: false; error: unknown }; @@ -52,6 +65,7 @@ export async function withEarlyStreamKeepalive( const thresholdMs = Math.max(0, options.thresholdMs ?? 2_000); const intervalMs = Math.max(250, options.intervalMs ?? 2_500); const signal = options.signal ?? null; + const keepaliveFrame = options.keepaliveFrame ?? KEEPALIVE_FRAME; // Settle into a tagged result so neither race branch leaves an unhandled // rejection when the threshold timer wins. @@ -88,7 +102,7 @@ export async function withEarlyStreamKeepalive( const interval = setInterval(() => { if (stopped) return; try { - controller.enqueue(KEEPALIVE_FRAME); + controller.enqueue(keepaliveFrame); } catch { stopped = true; clearInterval(interval); @@ -98,8 +112,11 @@ export async function withEarlyStreamKeepalive( interval.unref?.(); } // First keepalive immediately on commit so the client sees a byte right away. + // Use the configured frame (e.g. ANTHROPIC_PING_FRAME) — an SSE comment here + // would be ignored by Anthropic clients' watchdog on a sub-interval gap, + // defeating the keepalive for exactly the case it targets. try { - controller.enqueue(KEEPALIVE_FRAME); + controller.enqueue(keepaliveFrame); } catch { /* consumer already gone */ } diff --git a/open-sse/utils/jsonToSse.ts b/open-sse/utils/jsonToSse.ts index 7947d2a761..8fa339c84c 100644 --- a/open-sse/utils/jsonToSse.ts +++ b/open-sse/utils/jsonToSse.ts @@ -13,6 +13,7 @@ * Returns "" when the text is not a parseable chat-completion object with at * least one choice — callers then fall back to the original (error) handling. */ +import { getUnsupportedReasoningValue } from "./reasoningFields.ts"; type JsonRecord = Record; @@ -20,6 +21,46 @@ function isRecord(value: unknown): value is JsonRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } +function nonEmptyString(value: unknown): string { + return typeof value === "string" && value.length > 0 ? value : ""; +} + +function addReadableReasoning(message: JsonRecord, delta: JsonRecord): boolean { + const reasoningContent = nonEmptyString(message.reasoning_content); + if (reasoningContent) { + delta.reasoning_content = reasoningContent; + return true; + } + + const reasoning = nonEmptyString(message.reasoning); + if (reasoning) { + delta.reasoning = reasoning; + return true; + } + + return false; +} + +function addUnsupportedReasoning(message: JsonRecord, delta: JsonRecord) { + const reasoningContent = getUnsupportedReasoningValue(message); + if (reasoningContent) { + delta.reasoning_content = reasoningContent; + } +} + +function buildReasoningDelta(message: JsonRecord): JsonRecord | null { + const delta: JsonRecord = {}; + if (Array.isArray(message.reasoning_details)) { + delta.reasoning_details = message.reasoning_details; + } + + if (!addReadableReasoning(message, delta)) { + addUnsupportedReasoning(message, delta); + } + + return Object.keys(delta).length > 0 ? delta : null; +} + function sseEvent(payload: JsonRecord): string { return `data: ${JSON.stringify(payload)}\n\n`; } @@ -60,8 +101,9 @@ export function synthesizeOpenAiSseFromJson(jsonText: string): string { }; emitDelta({ role }); - if (typeof message.reasoning_content === "string" && message.reasoning_content.length > 0) { - emitDelta({ reasoning_content: message.reasoning_content }); + const reasoningDelta = buildReasoningDelta(message); + if (reasoningDelta) { + emitDelta(reasoningDelta); } if (typeof message.content === "string" && message.content.length > 0) { emitDelta({ content: message.content }); @@ -71,7 +113,9 @@ export function synthesizeOpenAiSseFromJson(jsonText: string): string { } const finishReason = - typeof choice.finish_reason === "string" && choice.finish_reason ? choice.finish_reason : "stop"; + typeof choice.finish_reason === "string" && choice.finish_reason + ? choice.finish_reason + : "stop"; const finalChoice: JsonRecord = { index, delta: {}, finish_reason: finishReason }; const finalChunk: JsonRecord = { ...base, choices: [finalChoice] }; if (isRecord(parsed.usage)) finalChunk.usage = parsed.usage; diff --git a/open-sse/utils/passthroughTailProcessor.ts b/open-sse/utils/passthroughTailProcessor.ts index cbc00f6987..82b57da8aa 100644 --- a/open-sse/utils/passthroughTailProcessor.ts +++ b/open-sse/utils/passthroughTailProcessor.ts @@ -7,6 +7,7 @@ import { stringifyIdValue, stripResponsesLifecycleEcho, } from "./responsesStreamHelpers.ts"; +import { getAnyReasoningValue } from "./reasoningFields.ts"; type JsonRecord = Record; @@ -200,12 +201,7 @@ function handleOpenAiTailPayload(parsed: JsonRecord, context: PassthroughTailPro context.appendPassthroughContent(delta.content); context.addTotalContentLength(delta.content.length); } - const reasoningDelta = - typeof delta.reasoning_content === "string" - ? delta.reasoning_content - : typeof delta.reasoning === "string" - ? delta.reasoning - : ""; + const reasoningDelta = getAnyReasoningValue(delta); if (reasoningDelta) { context.appendPassthroughReasoning(reasoningDelta); } diff --git a/open-sse/utils/publicCreds.ts b/open-sse/utils/publicCreds.ts index 821c9f7538..4862f34c81 100644 --- a/open-sse/utils/publicCreds.ts +++ b/open-sse/utils/publicCreds.ts @@ -179,6 +179,11 @@ const EMBEDDED_DEFAULTS = { github_copilot_id: [ 38, 27, 95, 71, 16, 90, 69, 67, 4, 29, 72, 22, 90, 91, 12, 0, 75, 19, 8, 87, ], + // Grok Build CLI (xAI) — public oauth client id (import-token flow) + grok_id: [ + 13, 92, 15, 89, 66, 91, 76, 70, 72, 29, 71, 70, 3, 65, 93, 84, 72, 23, 28, 87, 92, 88, 15, 95, + 91, 22, 71, 87, 20, 66, 67, 86, 13, 81, 81, 21, + ], } as const; export type EmbeddedDefaultKey = keyof typeof EMBEDDED_DEFAULTS; diff --git a/open-sse/utils/reasoningFields.ts b/open-sse/utils/reasoningFields.ts new file mode 100644 index 0000000000..e0d045e10b --- /dev/null +++ b/open-sse/utils/reasoningFields.ts @@ -0,0 +1,72 @@ +type JsonRecord = Record; + +export function asReasoningRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function nonEmptyString(value: unknown): string { + return typeof value === "string" && value.length > 0 ? value : ""; +} + +export function extractReasoningDetailsText(value: unknown): string { + const record = asReasoningRecord(value); + if (!Array.isArray(record.reasoning_details)) return ""; + return record.reasoning_details + .map((detail) => { + const item = asReasoningRecord(detail); + return nonEmptyString(item.text) || nonEmptyString(item.content); + }) + .join(""); +} + +export function getReadableReasoningValue(value: unknown): string { + const record = asReasoningRecord(value); + return nonEmptyString(record.reasoning_content) || nonEmptyString(record.reasoning); +} + +export function getUnsupportedReasoningValue(value: unknown): string { + const record = asReasoningRecord(value); + return ( + nonEmptyString(record.reasoning_text) || + nonEmptyString(record.thinking) || + nonEmptyString(record.thought) || + extractReasoningDetailsText(record) + ); +} + +export function getAnyReasoningValue(value: unknown): string { + return getReadableReasoningValue(value) || getUnsupportedReasoningValue(value); +} + +export function hasUnsupportedReasoningSignal(value: unknown): boolean { + const record = asReasoningRecord(value); + return Boolean( + !getReadableReasoningValue(record) && + (nonEmptyString(record.reasoning_text) || + nonEmptyString(record.thinking) || + nonEmptyString(record.thought) || + (Array.isArray(record.reasoning_details) && record.reasoning_details.length > 0)) + ); +} + +export function hasAnyReasoningSignal(value: unknown): boolean { + const record = asReasoningRecord(value); + return Boolean( + getReadableReasoningValue(record) || + nonEmptyString(record.reasoning_text) || + nonEmptyString(record.thinking) || + nonEmptyString(record.thought) || + (Array.isArray(record.reasoning_details) && record.reasoning_details.length > 0) + ); +} + +export function copyOpenAICompatibleReasoningFields(source: JsonRecord, target: JsonRecord) { + if (source.reasoning_content !== undefined) target.reasoning_content = source.reasoning_content; + if (source.reasoning !== undefined) target.reasoning = source.reasoning; + if (source.reasoning_text !== undefined) target.reasoning_text = source.reasoning_text; + if (Array.isArray(source.reasoning_details)) target.reasoning_details = source.reasoning_details; + if (!getReadableReasoningValue(target)) { + const mirrored = getUnsupportedReasoningValue(source); + if (mirrored) target.reasoning_content = mirrored; + } +} diff --git a/open-sse/utils/socksConnectorWithFamily.ts b/open-sse/utils/socksConnectorWithFamily.ts index 8cf71acd3f..37f982ba5e 100644 --- a/open-sse/utils/socksConnectorWithFamily.ts +++ b/open-sse/utils/socksConnectorWithFamily.ts @@ -1,6 +1,27 @@ import { Agent, buildConnector, type Dispatcher } from "undici"; import { SocksClient, type SocksProxy } from "socks"; +const DEFAULT_SOCKS_HANDSHAKE_TIMEOUT_MS = 10_000; +const MAX_SOCKS_HANDSHAKE_TIMEOUT_MS = 120_000; + +/** + * Resolve the SOCKS5 handshake (connect) timeout, operator-tunable via + * `SOCKS_HANDSHAKE_TIMEOUT_MS` (#5109). Under a saturated per-host pool the real + * handshake to a residential gateway can exceed the 10s default even though the + * proxy is reachable, so high-concurrency deployments can raise it without a + * code change. Invalid / non-positive values fall back to the default; values + * above the ceiling are clamped. + */ +export function resolveSocksHandshakeTimeoutMs( + env: Record = process.env +): number { + const raw = env.SOCKS_HANDSHAKE_TIMEOUT_MS; + if (raw == null || raw.trim() === "") return DEFAULT_SOCKS_HANDSHAKE_TIMEOUT_MS; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_SOCKS_HANDSHAKE_TIMEOUT_MS; + return Math.min(Math.floor(parsed), MAX_SOCKS_HANDSHAKE_TIMEOUT_MS); +} + /** The net.connect family options pinned for the SOCKS proxy hop. */ export function buildSocksFamilySocketOptions(family: 4 | 6 | null): Record { if (family === 6) return { family: 6, autoSelectFamily: false }; @@ -36,7 +57,7 @@ function socksConnectorWithFamily( const r = await SocksClient.createConnection({ command: "connect", proxy, - timeout: 10_000, + timeout: resolveSocksHandshakeTimeoutMs(), destination: { host: hostname, port: resolvePort(protocol, port) }, existing_socket: httpSocket as never, socket_options: socketOptions as never, diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 6d54886840..10b08bca65 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -47,6 +47,12 @@ import { stripResponsesLifecycleEcho, } from "./responsesStreamHelpers.ts"; import { processBufferedPassthroughLine } from "./passthroughTailProcessor.ts"; +import { + getAnyReasoningValue, + getReadableReasoningValue, + getUnsupportedReasoningValue, + hasUnsupportedReasoningSignal, +} from "./reasoningFields.ts"; /** * Race a response body read against a timeout. @@ -1592,11 +1598,8 @@ export function createSSEStream(options: StreamOptions = {}) { : false; const hadNonStringTopLevelId = parsed?.id != null && typeof parsed.id !== "string"; - const hadReasoningAlias = !!( - parsed.choices?.[0]?.delta?.reasoning && - typeof parsed.choices[0].delta.reasoning === "string" && - !parsed.choices[0].delta.reasoning_content - ); + const rawDelta = parsed.choices?.[0]?.delta; + const hadReasoningAlias = hasUnsupportedReasoningSignal(rawDelta); parsed = sanitizeStreamingChunk(parsed); if ( @@ -1699,7 +1702,7 @@ export function createSSEStream(options: StreamOptions = {}) { } } - const content = delta?.content || delta?.reasoning_content; + const content = delta?.content; if (typeof content === "string") { totalContentLength += content.length; @@ -1720,6 +1723,10 @@ export function createSSEStream(options: StreamOptions = {}) { } } } + const reasoningDelta = getReadableReasoningValue(delta); + if (reasoningDelta) { + totalContentLength += reasoningDelta.length; + } { const guarded = applyTextualToolCallStreamingGuard( parsed as Record @@ -1727,10 +1734,10 @@ export function createSSEStream(options: StreamOptions = {}) { parsed = guarded.parsed as typeof parsed; textualToolCallConverted = guarded.textualToolCallConverted; } - if (typeof delta?.reasoning_content === "string") + if (reasoningDelta) passthroughAccumulatedReasoning = appendBoundedText( passthroughAccumulatedReasoning, - delta.reasoning_content + reasoningDelta ); const extracted = extractUsage(parsed); @@ -1791,7 +1798,12 @@ export function createSSEStream(options: StreamOptions = {}) { } clientPayload = parsed; - } catch {} + } catch { + // Skip non-JSON data lines silently — don't forward garbage to clients. + // Upstream providers sometimes return plain-text errors (HTML, rate-limit + // messages) in the SSE stream that would break downstream JSON decoders. + continue; + } } if (!injectedUsage) { @@ -1894,23 +1906,24 @@ export function createSSEStream(options: StreamOptions = {}) { } } } - if (parsed.choices?.[0]?.delta?.reasoning_content) { - const r = parsed.choices[0].delta.reasoning_content; - if (typeof r === "string") { - totalContentLength += r.length; - if (state?.accumulatedContent !== undefined) - state.accumulatedContent = appendBoundedText(state.accumulatedContent, r); - } + const openAiDelta = parsed.choices?.[0]?.delta; + const openAiReasoning = getReadableReasoningValue(openAiDelta); + if (openAiReasoning) { + totalContentLength += openAiReasoning.length; + if (state?.accumulatedContent !== undefined) + state.accumulatedContent = appendBoundedText( + state.accumulatedContent, + openAiReasoning + ); } - // Normalize `reasoning` alias → `reasoning_content` (NVIDIA kimi-k2.5 etc.) - if ( - parsed.choices?.[0]?.delta?.reasoning && - !parsed.choices?.[0]?.delta?.reasoning_content - ) { - const r = parsed.choices[0].delta.reasoning; - if (typeof r === "string") { + // Mirror only client-unsupported reasoning aliases into `reasoning_content`. + if (!openAiReasoning) { + const delta = openAiDelta; + const r = getUnsupportedReasoningValue(delta); + if (typeof r === "string" && r.length > 0) { parsed.choices[0].delta.reasoning_content = r; - delete parsed.choices[0].delta.reasoning; + delete parsed.choices[0].delta.thinking; + delete parsed.choices[0].delta.thought; totalContentLength += r.length; if (state?.accumulatedContent !== undefined) state.accumulatedContent = appendBoundedText(state.accumulatedContent, r); @@ -1957,7 +1970,7 @@ export function createSSEStream(options: StreamOptions = {}) { const translateHasContent = typeof parsed.delta?.text === "string" || typeof parsed.choices?.[0]?.delta?.content === "string" || - typeof parsed.choices?.[0]?.delta?.reasoning_content === "string"; + Boolean(getAnyReasoningValue(parsed.choices?.[0]?.delta)); if (translateHasContent && !contentAfterToolSeen) { const toolTs = toolFinishTime || pendingToolFinishTime; const lastChunkTs = lastToolCallChunkTime; diff --git a/open-sse/utils/streamFailureFinalization.ts b/open-sse/utils/streamFailureFinalization.ts index fc8a339ce7..67fc4bc8d6 100644 --- a/open-sse/utils/streamFailureFinalization.ts +++ b/open-sse/utils/streamFailureFinalization.ts @@ -87,16 +87,22 @@ export function finalizeStreamRequestLog({ export function createStreamFailureFinalizers({ isFailureCompletionRecorded, + isStreamCompletionRecorded = () => false, onStreamComplete, persistFailureUsage, onStreamFailure, }: { isFailureCompletionRecorded: () => boolean; + isStreamCompletionRecorded?: () => boolean; onStreamComplete: (payload: StreamCompletionPayload) => void; persistFailureUsage: (status: number, errorCode?: string) => void; onStreamFailure?: ((failure: StreamFailurePayload) => void) | null; }) { const handleStreamFailure = (failure: StreamFailurePayload) => { + if (isStreamCompletionRecorded()) { + return true; + } + const status = failure.status || HTTP_STATUS.BAD_GATEWAY; const message = failure.message || "Upstream stream error"; const code = failure.code || failure.type || String(status); @@ -124,25 +130,42 @@ export function createStreamFailureFinalizers({ return true; }; + const isClientClosedPipelineError = (message: string, statusCode: number) => { + const normalized = message.toLowerCase(); + return ( + statusCode === 499 || + normalized.includes("responseaborted") || + normalized.includes("controller is already closed") || + normalized.includes("readablestream is closed") || + normalized.includes("writablestream is closed") || + normalized.includes("aborterror") + ); + }; + let pipelineStreamFailureFinalized = false; const onPipelineStreamError: PipelineStreamErrorHandler = ({ message, statusCode }) => { if (pipelineStreamFailureFinalized) return true; pipelineStreamFailureFinalized = true; - const status = - Number.isFinite(statusCode) && statusCode >= 400 && statusCode <= 599 + const normalizedMessage = message || "Upstream stream error"; + const clientClosed = isClientClosedPipelineError(normalizedMessage, statusCode); + const status = clientClosed + ? 499 + : Number.isFinite(statusCode) && statusCode >= 400 && statusCode <= 599 ? statusCode : HTTP_STATUS.BAD_GATEWAY; - const normalizedMessage = message || "Upstream stream error"; - const code = normalizedMessage.toLowerCase().includes("terminated") - ? "stream_terminated" - : "stream_pipeline_error"; + const code = clientClosed + ? "client_disconnected" + : normalizedMessage.toLowerCase().includes("terminated") + ? "stream_terminated" + : "stream_pipeline_error"; + const type = clientClosed ? "client_disconnected" : "stream_error"; handleStreamFailure({ status, message: normalizedMessage, code, - type: "stream_error", + type, }); return true; }; diff --git a/open-sse/utils/streamHandler.ts b/open-sse/utils/streamHandler.ts index 1e813a978f..b1ca7dfe6a 100644 --- a/open-sse/utils/streamHandler.ts +++ b/open-sse/utils/streamHandler.ts @@ -5,8 +5,6 @@ import { PENDING_REQUEST_CLEARED_MARKER } from "./stream.ts"; // Stream handler with disconnect detection - shared for all providers -const DISCONNECT_ABORT_DELAY_MS = 2_000; - // Default budget for the pipeWithDisconnect raw-upstream stall watchdog. // Inherits STREAM_IDLE_TIMEOUT_MS so a single env knob still governs the // max time we tolerate silence from upstream. Reasoning models (Claude @@ -30,12 +28,13 @@ type StreamErrorEvent = { }; type StreamControllerOptions = { - onDisconnect?: (event: StreamDisconnectEvent) => void; + onDisconnect?: (event: StreamDisconnectEvent) => boolean | void; onError?: (event: StreamErrorEvent) => boolean | void; provider?: string; model?: string; connectionId?: string | null; clientResponseFormat?: string | null; + clientAbortSignal?: AbortSignal | null; }; type StreamController = ReturnType; @@ -143,6 +142,24 @@ function isPendingRequestClearedError(error: unknown): boolean { ); } +/** + * A client disconnect — the caller aborted the request or closed the SSE + * connection — is NOT a provider failure. It surfaces either as an + * AbortError/ResponseAborted, or, when OmniRoute then tries to enqueue another + * chunk into the now-closed response stream, as a "Controller is already closed" + * TypeError. Treating any of these as an upstream error wrongly cools down the + * account/connection, so the stream error path uses this to skip the provider + * failover/cooldown (the chatgpt-web / codex / antigravity executors already + * guard client aborts the same way). + */ +export function isClientDisconnectError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const name = (error as { name?: unknown }).name; + if (name === "AbortError" || name === "ResponseAborted") return true; + const message = (error as { message?: unknown }).message; + return typeof message === "string" && /Controller is already closed/i.test(message); +} + function getErrorMessage(error: unknown): string { if (error instanceof Error && error.message) return error.message; if (typeof error === "string" && error.trim().length > 0) return error; @@ -159,6 +176,28 @@ function getErrorStatusCode(error: unknown): number { return 502; } +function hasClientTerminalSseMarker(text: string, clientResponseFormat?: string | null): boolean { + if (/(?:^|\r?\n)data:\s*\[DONE\]\s*(?:\r?\n|$)/.test(text)) { + return true; + } + + if (isResponsesClientFormat(clientResponseFormat)) { + return ( + /(?:^|\r?\n)event:\s*response\.completed\s*(?:\r?\n|$)/.test(text) || + /"type"\s*:\s*"response\.completed"/.test(text) + ); + } + + if (clientResponseFormat === FORMATS.CLAUDE) { + return ( + /(?:^|\r?\n)event:\s*message_stop\s*(?:\r?\n|$)/.test(text) || + /"type"\s*:\s*"message_stop"/.test(text) + ); + } + + return false; +} + /** * Create stream controller with abort and disconnect detection * @param {object} options @@ -175,12 +214,13 @@ export function createStreamController({ model, connectionId, clientResponseFormat, + clientAbortSignal, }: StreamControllerOptions = {}) { const abortController = new AbortController(); const startTime = Date.now(); let disconnected = false; - let abortTimeout: ReturnType | null = null; let pendingRequestCleared = false; + let cleanupClientAbortSignal: (() => void) | null = null; const logStream = (status) => { const duration = Date.now() - startTime; @@ -208,7 +248,24 @@ export function createStreamController({ } catch {} }; - return { + const cleanupClientAbortListener = () => { + if (!cleanupClientAbortSignal) return; + cleanupClientAbortSignal(); + cleanupClientAbortSignal = null; + }; + + const getClientAbortReason = () => { + const reason = clientAbortSignal?.reason; + if (typeof reason === "string" && reason.trim().length > 0) { + return reason; + } + if (reason instanceof Error && reason.message) { + return reason.message; + } + return "request_signal_aborted"; + }; + + const controller = { signal: abortController.signal, startTime, @@ -218,6 +275,7 @@ export function createStreamController({ handleDisconnect: (reason = "client_closed") => { if (disconnected) return; disconnected = true; + cleanupClientAbortListener(); logStream(`disconnect: ${reason}`); @@ -225,10 +283,7 @@ export function createStreamController({ // fire when the client aborts mid-stream, so we must clean up here. clearPendingRequest(); - // Delay abort to allow cleanup - abortTimeout = setTimeout(() => { - abortController.abort(); - }, DISCONNECT_ABORT_DELAY_MS); + abortController.abort(reason); onDisconnect?.({ reason, duration: Date.now() - startTime }); }, @@ -237,20 +292,23 @@ export function createStreamController({ handleComplete: () => { if (disconnected) return; disconnected = true; + cleanupClientAbortListener(); logStream("complete"); - - if (abortTimeout) { - clearTimeout(abortTimeout); - abortTimeout = null; - } }, // Call on error handleError: (error: unknown) => { - if (abortTimeout) { - clearTimeout(abortTimeout); - abortTimeout = null; + cleanupClientAbortListener(); + + // A client disconnect is not a provider failure. If the client already went away + // (disconnected) or the error is a client abort / "Controller is already closed", + // skip the onError failover/cooldown path — otherwise one cancelled request marks + // the upstream connection unavailable. + if (disconnected || isClientDisconnectError(error)) { + clearPendingRequest(error); + logStream(disconnected ? "client_disconnect (post-abort)" : "client_disconnect"); + return; } const alreadyCleared = isPendingRequestClearedError(error); @@ -285,9 +343,28 @@ export function createStreamController({ logStream("error: unknown"); }, - abort: () => abortController.abort(), + abort: () => { + cleanupClientAbortListener(); + abortController.abort(); + }, clientResponseFormat, }; + + if (clientAbortSignal && typeof clientAbortSignal.addEventListener === "function") { + const handleClientAbort = () => { + controller.handleDisconnect(getClientAbortReason()); + }; + if (clientAbortSignal.aborted) { + queueMicrotask(handleClientAbort); + } else { + clientAbortSignal.addEventListener("abort", handleClientAbort, { once: true }); + cleanupClientAbortSignal = () => { + clientAbortSignal.removeEventListener("abort", handleClientAbort); + }; + } + } + + return controller; } function buildStreamErrorChunks( @@ -368,6 +445,23 @@ export function createNoopAbortWritable(): { export function createDisconnectAwareStream(transformStream, streamController) { const reader = transformStream.readable.getReader(); const writer = transformStream.writable.getWriter(); + const terminalDecoder = new TextDecoder(); + let terminalTail = ""; + let clientTerminalSeen = false; + + const noteClientChunk = (chunk: unknown) => { + if (clientTerminalSeen) return; + if (!(chunk instanceof Uint8Array)) return; + + terminalTail += terminalDecoder.decode(chunk, { stream: true }); + if (terminalTail.length > 4096) { + terminalTail = terminalTail.slice(-4096); + } + clientTerminalSeen = hasClientTerminalSseMarker( + terminalTail, + streamController.clientResponseFormat + ); + }; return new ReadableStream( { @@ -385,7 +479,23 @@ export function createDisconnectAwareStream(transformStream, streamController) { return; } controller.enqueue(value); + noteClientChunk(value); } catch (error) { + if (!streamController.isConnected()) { + try { + controller.close(); + } catch {} + return; + } + + if (clientTerminalSeen) { + streamController.handleComplete(); + try { + controller.close(); + } catch {} + return; + } + streamController.handleError(error); // T35: Encapsulate mid-stream errors as SSE events instead of abruptly aborting @@ -393,24 +503,28 @@ export function createDisconnectAwareStream(transformStream, streamController) { const errorMsg = getErrorMessage(error); const statusCode = getErrorStatusCode(error); - for (const chunk of buildStreamErrorChunks( - errorMsg, - statusCode, - streamController.clientResponseFormat - )) { - controller.enqueue(chunk); + try { + for (const chunk of buildStreamErrorChunks( + errorMsg, + statusCode, + streamController.clientResponseFormat + )) { + controller.enqueue(chunk); + } + } catch { + // The downstream may have closed while we were formatting the in-band + // error event. The original stream error has already been recorded. } - controller.close(); + try { + controller.close(); + } catch {} } }, - cancel(reason) { + async cancel(reason) { streamController.handleDisconnect(reason || "cancelled"); - reader.cancel(); - setTimeout(() => { - writer.abort(); - }, DISCONNECT_ABORT_DELAY_MS).unref?.(); + await Promise.allSettled([reader.cancel(reason), writer.abort(reason)]); }, }, { highWaterMark: 16384 } @@ -540,7 +654,9 @@ export function pipeWithDisconnect( }, }); - const transformedBody = providerResponse.body.pipeThrough(upstreamTap).pipeThrough(transformStream); + const transformedBody = providerResponse.body + .pipeThrough(upstreamTap) + .pipeThrough(transformStream); return createDisconnectAwareStream( { readable: transformedBody, writable: createNoopAbortWritable() }, wrappedController diff --git a/open-sse/utils/streamHelpers.ts b/open-sse/utils/streamHelpers.ts index ff12ee0f7d..bfa5a2aad2 100644 --- a/open-sse/utils/streamHelpers.ts +++ b/open-sse/utils/streamHelpers.ts @@ -12,6 +12,7 @@ */ import { FORMATS } from "../translator/formats.ts"; +import { hasAnyReasoningSignal } from "./reasoningFields.ts"; type SSEPayloadOptions = { eventType?: string; @@ -324,9 +325,7 @@ export function hasValuableContent(chunk: Record, format: strin const delta = isRecord(firstChoice?.delta) ? firstChoice.delta : null; if (!firstChoice || !delta) return false; if (typeof delta.content === "string" && delta.content.length > 0) return true; - if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) - return true; - if (typeof delta.reasoning_text === "string" && delta.reasoning_text.length > 0) return true; + if (hasAnyReasoningSignal(delta)) return true; if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) return true; if (firstChoice.finish_reason) return true; if (typeof delta.role === "string" && delta.role.length > 0) return true; diff --git a/package-lock.json b/package-lock.json index e828ea0f80..80d2143e81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.37", + "version": "3.8.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.37", + "version": "3.8.38", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -28551,7 +28551,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.37" + "version": "3.8.38" } } } diff --git a/package.json b/package.json index 2d31648785..da57433e0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.37", + "version": "3.8.38", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { @@ -92,13 +92,13 @@ "electron:build:mac": "npm run build && cd electron && npm run build:mac", "electron:build:linux": "npm run build && cd electron && npm run build:linux", "electron:smoke:packaged": "node scripts/dev/smoke-electron-packaged.mjs", - "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts\"", - "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts\"", - "test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts\"", - "test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts\"", + "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", "test:unit:shard": "concurrently --kill-others-on-fail -n s1,s2 \"npm:test:unit:shard:1\" \"npm:test:unit:shard:2\"", - "test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts\"", - "test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts\"", + "test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", "test:plan3": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/plan3-p0.test.ts", "test:fixes": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/fixes-p1.test.ts", "test:security": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/security-fase01.test.ts", @@ -173,7 +173,11 @@ "typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json", "backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts", "env:sync": "node scripts/dev/sync-env.mjs", - "test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts", + "test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"", + "test:combo:matrix": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-matrix/*.test.ts\"", + "test:combo:live": "cross-env RUN_COMBO_LIVE=1 DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-live/*.live.test.ts\"", + "test:combo:live:vps": "node scripts/test/combo-live-vps.mjs", + "test:combo:live:vps:failover": "node scripts/test/combo-live-vps.mjs --failover", "test:heap": "node --expose-gc --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit tests/integration/heap-growth.test.ts", "test:chaos": "cross-env RUN_CHAOS_INT=1 node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/resilience-chaos.test.ts", "test:e2e": "node scripts/dev/run-playwright-tests.mjs test tests/e2e/*.spec.ts", @@ -183,7 +187,7 @@ "test:mutation": "stryker run", "test:ecosystem": "node scripts/dev/run-ecosystem-tests.mjs", "test:system": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/e2e/system-failover.test.ts", - "test:coverage": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts\"", + "test:coverage": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", "test:coverage:legacy": "c8 --output-dir coverage --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx --test tests/unit/*.test.ts", "coverage:report": "cross-env NODE_OPTIONS=--max-old-space-size=8192 c8 report --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov", "coverage:summary": "node scripts/check/test-report-summary.mjs --input coverage/coverage-summary.json --output coverage/coverage-report.md", diff --git a/scripts/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs index e948bd90e4..6bcfe69c25 100644 --- a/scripts/check/check-db-rules.mjs +++ b/scripts/check/check-db-rules.mjs @@ -70,6 +70,7 @@ export const INTENTIONALLY_INTERNAL = new Set([ "stateReset", // db-internal: 3 callers dentro de src/lib/db/ (core, backup, apiKeys) para coordenação de reset "stats", // intentionally-internal: src/app/api/settings/database/refresh-stats/route.ts "tierConfig", // intentionally-internal: open-sse/services/tierResolver.ts (require() dinâmico) + "webSessionDedup", // db-internal: importado só por db/providers.ts (webSessionCredentialKey/parseProviderSpecificData — helpers puros de dedup de credencial web-session split do providers.ts, #3368 PR6) ]); // Alias para retrocompatibilidade com os testes existentes que importam KNOWN_UNEXPORTED. diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index 93cbb2066a..0ecac07688 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -93,6 +93,11 @@ const IGNORE_FROM_CODE = new Set([ "PR_BODY", // CLI machine-id token opt-out (server-side flag; not user-configurable via .env). "OMNIROUTE_DISABLE_CLI_TOKEN", + // Gated combo live-smoke harness (scripts/test/_vpsClient.mjs) — override the VPS HTTP + // smoke target host/key. Test/CI-only signals with safe defaults + // ("http://192.168.0.15:20128" / null), never OmniRoute runtime config (#5151). + "COMBO_LIVE_BASE_URL", + "COMBO_LIVE_API_KEY", // update-notifier opt-out for the CLI binary. "OMNIROUTE_NO_UPDATE_NOTIFIER", // Headless CLI execution flag for Electron. diff --git a/scripts/check/check-error-helper.mjs b/scripts/check/check-error-helper.mjs index 0e49fc2eba..c226426ab1 100644 --- a/scripts/check/check-error-helper.mjs +++ b/scripts/check/check-error-helper.mjs @@ -43,13 +43,6 @@ export const KNOWN_MISSING_ERROR_HELPER = new Set([ // --- original open-sse/executors + handlers scope (pre-6A.8) --- // --- 6A.8 expanded scope: src/app/api/**/route.ts pre-existing violations --- // TODO(6A.8): pre-existing, triage — route through buildErrorBody()/sanitizeErrorMessage() - "src/app/api/cli-tools/backups/route.ts", - "src/app/api/cli-tools/guide-settings/[toolId]/route.ts", - "src/app/api/logs/export/route.ts", - "src/app/api/models/catalog/route.ts", - "src/app/api/providers/test-batch/route.ts", - "src/app/api/settings/import-json/route.ts", - "src/app/api/usage/proxy-logs/route.ts", ]); // Import specifiers that count as "uses the error helper" (path ends in utils/error). @@ -136,9 +129,7 @@ function forwardsRawError(source) { if (m && !/sanitize/i.test(line)) tainted.add(m[1]); } const taintedUse = - tainted.size > 0 - ? new RegExp(String.raw`\b(?:${[...tainted].join("|")})\b`) - : null; + tainted.size > 0 ? new RegExp(String.raw`\b(?:${[...tainted].join("|")})\b`) : null; // Pass 2: scan for leak lines. for (let i = 0; i < lines.length; i++) { diff --git a/scripts/check/check-test-discovery.mjs b/scripts/check/check-test-discovery.mjs index 17015af954..2bb6ee0e03 100644 --- a/scripts/check/check-test-discovery.mjs +++ b/scripts/check/check-test-discovery.mjs @@ -53,11 +53,15 @@ export const COLLECTORS = [ // "vitest" e explodem no node runner). Subdir novo: adicione aqui E nos scripts // (o drift-check + o gate de órfãos forçam a manutenção em sincronia). { - glob: "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts", + glob: "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts", sources: ["package.json", ".github/workflows/ci.yml"], }, // Node native runner — test:integration (top-level only; tests/integration/services/ NÃO roda) { glob: "tests/integration/*.test.ts", sources: ["package.json"] }, + // Node native runner — test:combo:matrix / test:integration (combo strategy decision matrix, 17 strategies) + { glob: "tests/integration/combo-matrix/*.test.ts", sources: ["package.json"] }, + // Node native runner — test:combo:live (gated real-upstream smoke; RUN_COMBO_LIVE=1 + VPS creds) + { glob: "tests/integration/combo-live/*.live.test.ts", sources: ["package.json"] }, // Node native runner — test:system { glob: "tests/e2e/system-failover.test.ts", sources: ["package.json"] }, // vitest.mcp.config.ts — test:vitest diff --git a/scripts/sre/oncall-rotation.mjs b/scripts/sre/oncall-rotation.mjs new file mode 100644 index 0000000000..436fafdcd6 --- /dev/null +++ b/scripts/sre/oncall-rotation.mjs @@ -0,0 +1,356 @@ +#!/usr/bin/env node +/** + * On-call rotation calculator. + * + * PagerDuty-style rotation over a list of engineers with a configurable + * shift length (hours). Pure Node stdlib — no `npm install`. File-based + * state: the rotation definition lives in a JSON file, and the output + * (handoffs, current on-call) is also a JSON file. + * + * Why a custom calculator instead of just `npm install pd-cli`: + * - We want the rotation to work offline (on the operator's laptop, + * before they have VPN). + * - The PagerDuty schedule XML format has corner cases (DST, week + * boundaries, mixed-timezone engineers) that we control here. + * - We need to be able to ask "who was on-call at 2026-06-12T07:00:00-07:00?" + * without depending on PagerDuty being reachable. + * + * CLI: + * node scripts/sre/oncall-rotation.mjs rotation.json current + * node scripts/sre/oncall-rotation.mjs rotation.json handoff --at 2026-06-25T09:00:00-07:00 + * node scripts/sre/oncall-rotation.mjs rotation.json range --from 2026-06-01 --to 2026-06-30 + * + * Rotation file format: + * { + * "timezone": "America/Los_Angeles", + * "shiftHours": 168, // 1 week + * "startsAt": "2026-06-01T09:00:00-07:00", + * "members": ["alice", "bob", "carol", "dave"] + * } + * + * Salvaged from closed PR #5057 (base-stale; reimplemented on release). + */ + +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import process from "node:process"; + +// ── Library API ────────────────────────────────────────────────────────────── + +/** + * @typedef {Object} Rotation + * @property {string} timezone IANA timezone identifier (e.g. "America/Los_Angeles") + * @property {number} shiftHours Hours per shift (168 = 1 week) + * @property {string} startsAt ISO-8601 datetime (the start of member[0]'s first shift) + * @property {string[]} members Ordered list of engineer handles + */ + +/** + * Format a Date in the given IANA timezone using `Intl.DateTimeFormat`. + * Returns an ISO-8601 string with the timezone offset, e.g. + * `2026-06-25T09:00:00-07:00`. + * + * @param {Date} date + * @param {string} timezone + * @returns {string} + */ +export function formatInTimezone(date, timezone) { + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZoneName: "longOffset", + }); + const parts = dtf.formatToParts(date); + const get = (type) => parts.find((p) => p.type === type)?.value ?? ""; + const year = get("year"); + const month = get("month"); + const day = get("day"); + const hour = get("hour"); + const minute = get("minute"); + const second = get("second"); + // `timeZoneName: "longOffset"` yields "GMT-07:00" — extract the offset. + const tzName = get("timeZoneName").replace(/^GMT/, ""); + return `${year}-${month}-${day}T${hour}:${minute}:${second}${tzName || "Z"}`; +} + +/** + * Resolve a Date in the given timezone to the corresponding UTC ms. + * We do this by formatting the date in the timezone, parsing the formatted + * string as UTC, then computing the offset difference. + * + * @param {Date} utc + * @param {string} timezone + * @returns {number} UTC ms that, when formatted in `timezone`, equals `utc` + */ +export function utcForTimezone(utc, timezone) { + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + const parts = dtf.formatToParts(utc); + const get = (type) => parts.find((p) => p.type === type)?.value ?? ""; + // Build a UTC date from the formatted parts, then derive the offset. + const asUtc = Date.UTC( + Number(get("year")), + Number(get("month")) - 1, + Number(get("day")), + Number(get("hour")), + Number(get("minute")), + Number(get("second")) + ); + // The difference between `asUtc` and the wall clock in UTC ms gives the + // timezone offset (in ms). Adding that offset to the wall-clock-interpreted- + // as-UTC time gives the real UTC instant. + const offsetMs = asUtc - Date.UTC( + utc.getUTCFullYear(), + utc.getUTCMonth(), + utc.getUTCDate(), + utc.getUTCHours(), + utc.getUTCMinutes(), + utc.getUTCSeconds() + ); + return asUtc - offsetMs; +} + +/** + * Compute the rotation index for a given UTC instant. + * + * The rotation index is `floor((utcMs - startMs) / shiftMs) mod members.length`. + * Negative mod in JS is tricky — we add `members.length` and mod again to keep + * the index non-negative. + * + * @param {number} utcMs UTC millisecond timestamp + * @param {Rotation} rotation + * @returns {number} index into `rotation.members` + */ +export function rotationIndex(utcMs, rotation) { + const startMs = utcForTimezone(new Date(rotation.startsAt), rotation.timezone); + const shiftMs = rotation.shiftHours * 60 * 60 * 1000; + const elapsed = utcMs - startMs; + const rawIndex = Math.floor(elapsed / shiftMs); + const len = rotation.members.length; + return ((rawIndex % len) + len) % len; +} + +/** + * Compute the start and end UTC ms of the shift that contains `utcMs`. + * + * @param {number} utcMs + * @param {Rotation} rotation + * @returns {{ startsAt: string, endsAt: string, member: string, index: number }} + */ +export function shiftFor(utcMs, rotation) { + const startMs = utcForTimezone(new Date(rotation.startsAt), rotation.timezone); + const shiftMs = rotation.shiftHours * 60 * 60 * 1000; + const elapsed = utcMs - startMs; + const shiftIndex = Math.floor(elapsed / shiftMs); + const shiftStart = startMs + shiftIndex * shiftMs; + const shiftEnd = shiftStart + shiftMs; + const memberIndex = ((shiftIndex % rotation.members.length) + rotation.members.length) % rotation.members.length; + return { + startsAt: new Date(shiftStart).toISOString(), + endsAt: new Date(shiftEnd).toISOString(), + member: rotation.members[memberIndex], + index: memberIndex, + shiftNumber: shiftIndex, + }; +} + +/** + * List every shift in the rotation between two ISO-8601 datetimes. + * + * @param {string} fromIso + * @param {string} toIso + * @param {Rotation} rotation + * @returns {Array<{ startsAt: string, endsAt: string, member: string, index: number, shiftNumber: number }>} + */ +export function shiftsInRange(fromIso, toIso, rotation) { + const fromMs = Date.parse(fromIso); + const toMs = Date.parse(toIso); + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) { + throw new Error(`invalid date: from=${fromIso} to=${toIso}`); + } + if (toMs <= fromMs) { + throw new Error(`range is empty or reversed: ${fromIso} >= ${toIso}`); + } + const startMs = utcForTimezone(new Date(rotation.startsAt), rotation.timezone); + const shiftMs = rotation.shiftHours * 60 * 60 * 1000; + const len = rotation.members.length; + const halfShift = shiftMs / 2; + const out = []; + // Start at the first shift whose end is >= fromMs. + const firstShift = Math.floor((fromMs - startMs) / shiftMs); + const lastShift = Math.ceil((toMs - startMs) / shiftMs); + for (let i = firstShift; i < lastShift; i += 1) { + const sStart = startMs + i * shiftMs; + const sEnd = sStart + shiftMs; + // Skip shifts that start before the rotation was ever defined — they + // would resolve to a member, but the rotation didn't exist yet so + // there's no handoff record to point at. + if (sStart < startMs) continue; + // A shift is included if it overlaps the range AND either + // - it started before the range and is still ongoing at fromMs, OR + // - it started inside the range AND less than half of it extends past toMs. + // The half-shift gate prevents a "next-cycle" shift from leaking into a + // range that just barely clips its start. + if (sEnd <= fromMs) continue; + if (sStart >= toMs) break; + const startedBeforeRange = sStart < fromMs; + const overshoots = sEnd - toMs; + if (!startedBeforeRange && overshoots >= halfShift) continue; + const memberIndex = ((i % len) + len) % len; + out.push({ + startsAt: new Date(sStart).toISOString(), + endsAt: new Date(sEnd).toISOString(), + member: rotation.members[memberIndex], + index: memberIndex, + shiftNumber: i, + }); + } + return out; +} + +// ── CLI ────────────────────────────────────────────────────────────────────── + +function loadRotation(path) { + if (!existsSync(path)) { + process.stderr.write(`oncall-rotation: file not found: ${path}\n`); + process.exit(2); + } + const raw = readFileSync(path, "utf8"); + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + process.stderr.write(`oncall-rotation: invalid JSON in ${path}: ${err.message}\n`); + process.exit(2); + } + // Minimal validation — keep the script forgiving but loud. + for (const key of ["timezone", "shiftHours", "startsAt", "members"]) { + if (!(key in parsed)) { + process.stderr.write(`oncall-rotation: missing field "${key}" in ${path}\n`); + process.exit(2); + } + } + if (!Array.isArray(parsed.members) || parsed.members.length === 0) { + process.stderr.write(`oncall-rotation: "members" must be a non-empty array\n`); + process.exit(2); + } + if (typeof parsed.shiftHours !== "number" || parsed.shiftHours <= 0) { + process.stderr.write(`oncall-rotation: "shiftHours" must be a positive number\n`); + process.exit(2); + } + return parsed; +} + +function printHelp() { + process.stdout.write(`Usage: oncall-rotation.mjs [options] + +Commands: + current Print the shift that contains "now" (UTC). + handoff --at Print the shift containing the given instant. + range --from --to List every shift overlapping the range. + validate Validate the rotation file and print a summary. + +Rotation file format (JSON): + { + "timezone": "America/Los_Angeles", + "shiftHours": 168, + "startsAt": "2026-06-01T09:00:00-07:00", + "members": ["alice", "bob", "carol"] + } +`); +} + +function parseCommandArgs(argv) { + const out = { command: null, from: null, at: null, to: null }; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a === "--from") { + out.from = argv[++i]; + } else if (a === "--to") { + out.to = argv[++i]; + } else if (a === "--at") { + out.at = argv[++i]; + } + } + out.command = argv[0] ?? null; + return out; +} + +function main() { + const args = process.argv.slice(2); + if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { + printHelp(); + return; + } + const [rotationPath, ...rest] = args; + if (!rotationPath || rest.length === 0) { + printHelp(); + process.exit(2); + } + const rotation = loadRotation(rotationPath); + const cmd = parseCommandArgs(rest); + + if (cmd.command === "current") { + const nowMs = Date.now(); + const shift = shiftFor(nowMs, rotation); + process.stdout.write(`${JSON.stringify({ now: new Date(nowMs).toISOString(), ...shift }, null, 2)}\n`); + return; + } + if (cmd.command === "handoff") { + if (!cmd.at) { + process.stderr.write("oncall-rotation: handoff requires --at \n"); + process.exit(2); + } + const atMs = Date.parse(cmd.at); + if (!Number.isFinite(atMs)) { + process.stderr.write(`oncall-rotation: invalid --at datetime: ${cmd.at}\n`); + process.exit(2); + } + const shift = shiftFor(atMs, rotation); + process.stdout.write(`${JSON.stringify({ at: new Date(atMs).toISOString(), ...shift }, null, 2)}\n`); + return; + } + if (cmd.command === "range") { + if (!cmd.from || !cmd.to) { + process.stderr.write("oncall-rotation: range requires --from --to \n"); + process.exit(2); + } + const shifts = shiftsInRange(cmd.from, cmd.to, rotation); + process.stdout.write(`${JSON.stringify({ from: cmd.from, to: cmd.to, shifts }, null, 2)}\n`); + return; + } + if (cmd.command === "validate") { + const summary = { + timezone: rotation.timezone, + shiftHours: rotation.shiftHours, + startsAt: rotation.startsAt, + members: rotation.members, + firstShift: shiftFor(Date.parse(rotation.startsAt), rotation), + lastShift: shiftFor(Date.parse(rotation.startsAt) + rotation.members.length * rotation.shiftHours * 3600_000 - 1, rotation), + }; + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + return; + } + + process.stderr.write(`oncall-rotation: unknown command: ${cmd.command}\n`); + printHelp(); + process.exit(2); +} + +import { pathToFileURL } from "node:url"; +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} \ No newline at end of file diff --git a/scripts/sre/redact-logs.mjs b/scripts/sre/redact-logs.mjs new file mode 100644 index 0000000000..88f9806234 --- /dev/null +++ b/scripts/sre/redact-logs.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +/** + * PII redaction for log shipping. + * + * Streams input (stdin or files) to stdout, replacing sensitive tokens + * with stable redaction markers. Pure Node.js stdlib — no `npm install`. + * + * Recognised patterns (in order, longest match wins per position): + * + * 1. Anthropic API keys (sk-ant-...) → [REDACTED_API_KEY] + * 2. Google API keys (AIza...) → [REDACTED_API_KEY] + * 3. GitHub tokens (ghp_/gho_/ghu_/...) → [REDACTED_API_KEY] + * 4. OpenAI keys (sk-..., sk-proj-...) → [REDACTED_API_KEY] + * 5. AWS access keys (AKIA/ASIA) → [REDACTED_AWS_KEY] + * 6. Bearer tokens → [REDACTED_BEARER] + * 7. Email addresses → [REDACTED_EMAIL] + * 8. Generic api_key=value pairs → [REDACTED_API_KEY] + * 9. IPv4 addresses → [REDACTED_IPV4] + * 10. IPv6 addresses → [REDACTED_IPV6] + * + * Provider-specific patterns are listed BEFORE the generic `sk-` rule so + * that an `sk-ant-...` key counts as `ANTHROPIC_KEY` (not `OPENAI_KEY`) + * for the per-call summary. + * + * Why stable markers: downstream log-search queries reference the + * redaction markers (e.g. "show me all log lines with [REDACTED_IPV4]"), + * which makes the redaction reversible by anyone with the original + * vault lookup, but never by a log-search reader alone. + * + * CLI: + * node scripts/sre/redact-logs.mjs < input.log > output.log + * node scripts/sre/redact-logs.mjs --file access.log --output out.log + * node scripts/sre/redact-logs.mjs --strict # exit non-zero on any match + * + * Library: + * import { redact, redactString, RedactTransform } from "./scripts/sre/redact-logs.mjs"; + * + * Salvaged from closed PR #5057 (base-stale; reimplemented on release). + */ + +import { createReadStream, createWriteStream } from "node:fs"; +import { TransformStream } from "node:stream/web"; +import process from "node:process"; + +// ── Pattern catalogue ───────────────────────────────────────────────────────── +// +// Each pattern is [name, regex, marker]. The regex uses the `g` flag so we can +// iterate with `matchAll`. Order matters: longer / more specific patterns go +// first so an `sk-ant-...` key counts as ANTHROPIC_KEY instead of OPENAI_KEY. + +export const REDACT_PATTERNS = Object.freeze([ + // 1. Anthropic keys: sk-ant-api03-... / sk-ant-... (must beat the generic sk-) + [ + "ANTHROPIC_KEY", + /\bsk-ant-[A-Za-z0-9_\-]{20,}\b/g, + "[REDACTED_API_KEY]", + ], + // 2. Google API keys: AIza... (39 chars total) + [ + "GOOGLE_KEY", + /\bAIza[A-Za-z0-9_\-]{35}\b/g, + "[REDACTED_API_KEY]", + ], + // 3. GitHub tokens (classic + fine-grained + PAT prefixes) + [ + "GITHUB_TOKEN", + /\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9]{30,}\b/g, + "[REDACTED_API_KEY]", + ], + // 4. OpenAI keys: sk-..., sk-proj-..., proj-... (after the more specific rules above) + [ + "OPENAI_KEY", + /\bsk-(?:proj-)?[A-Za-z0-9_\-]{20,}\b|\bproj-[A-Za-z0-9_\-]{20,}\b/g, + "[REDACTED_API_KEY]", + ], + // 5. AWS access keys — AKIA / ASIA prefixes, 20 chars total + [ + "AWS_KEY", + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, + "[REDACTED_AWS_KEY]", + ], + // 6. Bearer tokens — Authorization: Bearer xxxx (16+ chars) + [ + "BEARER", + /(?:Bearer|Authorization:\s*Bearer)\s+([A-Za-z0-9._\-+/=]{16,})/g, + "[REDACTED_BEARER]", + ], + // 7. Email — RFC 5322-ish; rejects obvious junk but stays compact. + [ + "EMAIL", + /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g, + "[REDACTED_EMAIL]", + ], + // 8. Generic api_key / apiKey / password= value pairs (12+ char secret) + [ + "GENERIC_KEY", + /\b(?:api[_-]?key|apikey|password|passwd|pwd|secret|token|auth)\s*[:=]\s*['"]?([A-Za-z0-9._\-+/=]{12,})['"]?/gi, + "[REDACTED_API_KEY]", + ], + // 9. IPv4 (incl. port) — strict octet bounds + [ + "IPV4", + /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)(?::\d{1,5})?\b/g, + "[REDACTED_IPV4]", + ], + // 10. IPv6 — full, compressed, ::1, ::ffff:1.2.3.4 + // Three alternative shapes: + // (a) full 8-group form (no `::`), + // (b) compressed form with `::` somewhere, + // (c) `::1` / `::` alone anchored by a non-hex lookbehind so it + // doesn't greedily extend `fe80::` into `fe80::foo`. + [ + "IPV6", + new RegExp( + [ + // (a) Full 8-group: 1:2:3:4:5:6:7:8 + "\\b(?:[A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}\\b", + // (b) Compressed with `::` somewhere in the middle (left side 1+ groups) + "\\b(?:[A-Fa-f0-9]{1,4}:){1,6}[A-Fa-f0-9]{1,4}::[A-Fa-f0-9]{1,4}(?::[A-Fa-f0-9]{1,4}){0,6}\\b", + "\\b(?:[A-Fa-f0-9]{1,4}:){1,7}:[A-Fa-f0-9]{1,4}(?::[A-Fa-f0-9]{1,4}){0,6}\\b", + // (c) Leading `::` (no left side): ::1, ::1:2, ::ffff:1.2.3.4 + "(? }} + */ +export function redactString(input) { + if (typeof input !== "string" || input.length === 0) { + return { output: input ?? "", counts: {} }; + } + const counts = {}; + let output = input; + for (const [name, regex, marker] of REDACT_PATTERNS) { + output = output.replace(regex, () => { + counts[name] = (counts[name] ?? 0) + 1; + return marker; + }); + } + return { output, counts }; +} + +/** + * Redact a single line. Convenience wrapper around redactString that does + * not allocate an intermediate object for the counts. + * + * @param {string} line + * @returns {string} + */ +export function redact(line) { + return redactString(line).output; +} + +/** + * Redact TransformStream. + * + * Implements the WHATWG TransformStream API so callers can do: + * await src.pipeThrough(new TextDecoderStream()).pipeThrough(new RedactTransform()).pipeTo(sink) + * + * Counts are accumulated on the stream instance (`.counts`). + */ +export class RedactTransform extends TransformStream { + constructor() { + const counts = {}; + super({ + transform(chunk, controller) { + const text = typeof chunk === "string" ? chunk : new TextDecoder("utf-8").decode(chunk); + const { output, counts: localCounts } = redactString(text); + for (const [name, n] of Object.entries(localCounts)) { + counts[name] = (counts[name] ?? 0) + n; + } + controller.enqueue(new TextEncoder().encode(output)); + }, + }); + // Attach counts as an enumerable own property so tests can read it. + Object.defineProperty(this, "counts", { + value: counts, + writable: false, + enumerable: true, + configurable: false, + }); + } +} + +// ── CLI ────────────────────────────────────────────────────────────────────── + +function parseArgs(argv) { + const args = { files: [], output: null, strict: false, help: false }; + for (let i = 2; i < argv.length; i += 1) { + const a = argv[i]; + if (a === "--file") { + args.files.push(argv[++i]); + } else if (a === "--output" || a === "-o") { + args.output = argv[++i]; + } else if (a === "--strict") { + args.strict = true; + } else if (a === "--help" || a === "-h") { + args.help = true; + } else { + process.stderr.write(`unknown argument: ${a}\n`); + process.exit(2); + } + } + return args; +} + +function printHelp() { + process.stdout.write(`Usage: redact-logs.mjs [options] + +Options: + --file Read from file (repeatable). Defaults to stdin. + --output, -o

Write to file. Defaults to stdout. + --strict Exit non-zero if any PII is detected. + --help, -h Show this help. + +Library: + import { redact, redactString, RedactTransform } from "./scripts/sre/redact-logs.mjs"; +`); +} + +async function main() { + const args = parseArgs(process.argv); + if (args.help) { + printHelp(); + return; + } + + const transform = new RedactTransform(); + + // Build a WHATWG ReadableStream from each input source. We open files / + // stdin as a Node Readable and convert it via Readable.toWeb(). + const { Readable } = await import("node:stream"); + const { Writable: WritableStreamWeb } = await import("node:stream/web"); + const sources = args.files.length > 0 ? args.files : ["-"]; + + for (const source of sources) { + const nodeSrc = source === "-" ? process.stdin : createReadStream(source, "utf8"); + const webSrc = Readable.toWeb(nodeSrc); + const webDecoded = webSrc.pipeThrough(new TextDecoderStream("utf-8")); + const webEncoded = webDecoded.pipeThrough(transform); + + const sink = args.output + ? createWriteStream(args.output, "utf8") + : process.stdout; + const webSink = WritableStreamWeb.toWeb(sink); + + try { + await webEncoded.pipeTo(webSink); + } catch (err) { + process.stderr.write(`redact-logs: ${err.message}\n`); + process.exit(1); + } + } + + const totals = transform.counts; + if (Object.keys(totals).length > 0) { + const summary = Object.entries(totals) + .map(([k, v]) => `${k}=${v}`) + .join(" "); + process.stderr.write(`redact-logs: redacted ${summary}\n`); + if (args.strict) { + process.exit(3); + } + } +} + +// Only run as CLI when this module is the entrypoint (not when imported as a +// library). `import.meta.url === pathToFileURL(process.argv[1]).href` is the +// canonical ESM check. +import { pathToFileURL } from "node:url"; +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/scripts/test/_vpsClient.mjs b/scripts/test/_vpsClient.mjs new file mode 100644 index 0000000000..e087f968ad --- /dev/null +++ b/scripts/test/_vpsClient.mjs @@ -0,0 +1,319 @@ +/** + * scripts/test/_vpsClient.mjs + * + * Reusable Phase-3 VPS HTTP client for OmniRoute combo live-smoke tests. + * NOT a test file — intentionally placed in scripts/test/ so check:test-discovery + * does not scan it. + * + * Combo create/delete mechanism: SSH-sqlite fallback. + * /api/combos requires management auth (returns 401 unauthenticated). + * We insert/delete rows directly via: + * execFileSync("ssh", ["root@192.168.0.15", "sqlite3", "/root/.omniroute/storage.sqlite", SQL]) + * Values are static test-scoped data — no untrusted interpolation. + * + * combos table schema (PRAGMA table_info): + * id TEXT PK, name TEXT NOT NULL, data TEXT NOT NULL (JSON blob), + * created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + * system_message TEXT, tool_filter_regex TEXT, + * context_cache_protection INTEGER DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0 + * + * The `data` column stores the full combo as JSON (name, models[], strategy, config, + * id, createdAt, updatedAt, version, sortOrder). + */ + +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- +const BASE_URL = process.env.COMBO_LIVE_BASE_URL ?? "http://192.168.0.15:20128"; +const API_KEY = process.env.COMBO_LIVE_API_KEY ?? null; +const VPS_SSH_HOST = "root@192.168.0.15"; +const VPS_DB_PATH = "/root/.omniroute/storage.sqlite"; + +// --------------------------------------------------------------------------- +// Nonce counter — increments per call so semantic cache cannot serve stale +// --------------------------------------------------------------------------- +let _nonceCounter = 0; +export function nonce() { + return ++_nonceCounter; +} + +// --------------------------------------------------------------------------- +// Shared fetch helpers +// --------------------------------------------------------------------------- +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (API_KEY) h["Authorization"] = `Bearer ${API_KEY}`; + return h; +} + +async function fetchJson(path, options = {}) { + const url = `${BASE_URL}${path}`; + const res = await fetch(url, { ...options, headers: { ...authHeaders(), ...(options.headers ?? {}) } }); + let body; + try { + body = await res.json(); + } catch { + body = null; + } + return { status: res.status, ok: res.ok, body }; +} + +// --------------------------------------------------------------------------- +// health() — GET /api/monitoring/health +// --------------------------------------------------------------------------- +export async function health() { + const { status, body } = await fetchJson("/api/monitoring/health"); + return { + status, + version: body?.version ?? null, + uptime: body?.uptime ?? null, + raw: body, + }; +} + +// --------------------------------------------------------------------------- +// chat() — POST /v1/chat/completions (non-streaming) +// --------------------------------------------------------------------------- +export async function chat(model, { maxTokens = 16, content } = {}) { + const n = nonce(); + const userContent = content ?? `ping ${n}`; + const payload = { + model, + stream: false, + max_tokens: maxTokens, + temperature: 0, + messages: [{ role: "user", content: userContent }], + }; + const { status, body } = await fetchJson("/v1/chat/completions", { + method: "POST", + body: JSON.stringify(payload), + }); + const text = body?.choices?.[0]?.message?.content ?? null; + return { + status, + model: body?.model ?? model, + text, + raw: body, + }; +} + +// --------------------------------------------------------------------------- +// Combo create/delete via SSH sqlite +// --------------------------------------------------------------------------- +function sshSqlite(sql) { + // Pipe SQL via stdin to avoid shell quoting issues with complex SQL statements. + // ssh + sqlite3 reads from stdin when no trailing SQL arg is given. + return execFileSync("ssh", [VPS_SSH_HOST, "sqlite3", VPS_DB_PATH], { + input: sql, + encoding: "utf8", + timeout: 15_000, + }).trim(); +} + +/** + * createCombo(def) — inserts a combo row via SSH sqlite. + * + * def shape: + * { name, strategy, models, config } + * + * models: array of "providerId/model" strings OR + * array of { providerId, model, connectionId?, weight? } objects. + * + * config: optional object (judgeModel, fusionTuning, etc.) + * + * Returns the combo id string. + */ +export function createCombo(def) { + const id = randomUUID(); + const now = new Date().toISOString(); + const strategy = def.strategy ?? "priority"; + const config = def.config ?? {}; + const name = def.name; + + // Normalise models to the shape the DB expects + const rawModels = def.models ?? []; + const models = rawModels.map((m, idx) => { + if (typeof m === "string") { + // "providerId/model" shorthand + const slashIdx = m.indexOf("/"); + const providerId = slashIdx >= 0 ? m.slice(0, slashIdx) : m; + const model = slashIdx >= 0 ? m.slice(slashIdx + 1) : m; + const slugName = name.toLowerCase().replace(/[^a-z0-9]/g, "-"); + const slugModel = model.toLowerCase().replace(/[^a-z0-9]/g, "-"); + return { + id: `${slugName}-model-${idx + 1}-${providerId}-${slugModel}-${randomUUID()}`, + kind: "model", + model: `${providerId}/${model}`, + providerId, + weight: 1, + }; + } + // Already an object + const providerId = m.providerId; + const model = m.model; + const slugName = name.toLowerCase().replace(/[^a-z0-9]/g, "-"); + const slugModel = (model ?? "").toLowerCase().replace(/[^a-z0-9]/g, "-"); + return { + id: m.id ?? `${slugName}-model-${idx + 1}-${providerId}-${slugModel}-${randomUUID()}`, + kind: "model", + model: model.includes("/") ? model : `${providerId}/${model}`, + providerId, + ...(m.connectionId ? { connectionId: m.connectionId } : {}), + weight: m.weight ?? 1, + }; + }); + + const dataObj = { + name, + models, + strategy, + config, + id, + createdAt: now, + updatedAt: now, + version: 2, + sortOrder: 9999, + }; + + const dataJson = JSON.stringify(dataObj).replace(/'/g, "''"); + const nameSafe = name.replace(/'/g, "''"); + const sql = `INSERT INTO combos (id, name, data, created_at, updated_at, sort_order) VALUES ('${id}', '${nameSafe}', '${dataJson}', '${now}', '${now}', 9999);`; + sshSqlite(sql); + return id; +} + +/** + * deleteCombo(nameOrId) — deletes a combo by name or id via SSH sqlite. + * Only deletes __live_test__* prefixed combos as a safety guard. + */ +export function deleteCombo(nameOrId) { + if (!nameOrId.startsWith("__live_test__")) { + throw new Error(`deleteCombo safety guard: refusing to delete '${nameOrId}' — only __live_test__* combos allowed.`); + } + const safe = nameOrId.replace(/'/g, "''"); + // Try delete by name first, then by id + sshSqlite(`DELETE FROM combos WHERE name='${safe}' OR id='${safe}';`); +} + +// --------------------------------------------------------------------------- +// listHealthyProviders(candidates) — probe each "provider/model" candidate +// --------------------------------------------------------------------------- +/** + * For each "provider/model" string, fire a minimal chat() and keep those + * returning HTTP 200 with non-empty text. + * + * @param {string[]} candidates - array of "provider/model" strings + * @returns {Promise} healthy candidates + */ +export async function listHealthyProviders(candidates) { + const results = await Promise.allSettled( + candidates.map(async (c) => { + const r = await chat(c, { maxTokens: 16 }); + return r.status === 200 && r.text ? c : null; + }) + ); + return results + .map((r) => (r.status === "fulfilled" ? r.value : null)) + .filter(Boolean); +} + +// --------------------------------------------------------------------------- +// Preflight self-check (run directly: node scripts/test/_vpsClient.mjs) +// --------------------------------------------------------------------------- +const isMain = process.argv[1]?.endsWith("_vpsClient.mjs") || + import.meta.url === `file://${process.argv[1]}`; + +if (isMain) { + (async () => { + console.log("=== OmniRoute VPS Phase-3 Preflight ==="); + console.log(`Base URL: ${BASE_URL}`); + console.log(`API key: ${API_KEY ? "set (Bearer)" : "not set (REQUIRE_API_KEY=false)"}`); + console.log(); + + // 1. Health + console.log("--- health() ---"); + try { + const h = await health(); + console.log(` status: ${h.status}`); + console.log(` version: ${h.version}`); + } catch (e) { + console.error(` ERROR: ${e.message}`); + } + + // 2. Combo mechanism probe + console.log(); + console.log("--- combo create/delete mechanism ---"); + console.log(" /api/combos GET (unauthenticated):", (() => { + try { + const r = execFileSync("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", `${BASE_URL}/api/combos`], { encoding: "utf8", timeout: 5000 }); + return r.trim(); + } catch { return "error"; } + })()); + console.log(" Mechanism: SSH sqlite fallback (management API requires auth)"); + console.log(" SSH host:", VPS_SSH_HOST); + console.log(" DB path:", VPS_DB_PATH); + + // 3. Create + delete a probe combo via SSH sqlite + console.log(); + console.log("--- createCombo / deleteCombo (SSH sqlite) ---"); + const probeName = "__live_test__probe"; + let probeId; + try { + probeId = createCombo({ + name: probeName, + strategy: "priority", + models: ["groq/llama-3.1-8b-instant"], + config: {}, + }); + console.log(` created id: ${probeId}`); + deleteCombo(probeName); + console.log(` deleted OK`); + } catch (e) { + console.error(` ERROR: ${e.message}`); + // Attempt cleanup on error + if (probeId) { + try { deleteCombo(probeName); } catch {} + } + } + + // 4. ollama-cloud chat probe + console.log(); + console.log("--- chat probe: ollama-cloud/glm-5.2 ---"); + try { + const r = await chat("ollama-cloud/glm-5.2", { maxTokens: 16 }); + console.log(` status: ${r.status}`); + console.log(` model: ${r.model}`); + console.log(` text: ${r.text ? r.text.slice(0, 80) : "(empty)"}`); + if (r.status !== 200 || !r.text) { + console.log(" NOTE: ollama-cloud/glm-5.2 did not return a valid response."); + console.log(" raw error:", JSON.stringify(r.raw?.error ?? r.raw).slice(0, 200)); + } + } catch (e) { + console.error(` ERROR: ${e.message}`); + } + + // 5. Quick healthy provider scan over a small candidate set + console.log(); + console.log("--- listHealthyProviders (subset scan) ---"); + const candidates = [ + "groq/llama-3.1-8b-instant", + "gemini/gemini-2.0-flash", + "deepseek/deepseek-chat", + "cerebras/llama3.1-8b", + "ollama-cloud/glm-5.2", + ]; + try { + const healthy = await listHealthyProviders(candidates); + console.log(` tested: ${candidates.join(", ")}`); + console.log(` healthy (${healthy.length}/${candidates.length}): ${healthy.join(", ") || "(none)"}`); + } catch (e) { + console.error(` ERROR: ${e.message}`); + } + + console.log(); + console.log("=== Preflight complete ==="); + })(); +} diff --git a/scripts/test/combo-live-vps.mjs b/scripts/test/combo-live-vps.mjs new file mode 100644 index 0000000000..de96e26c7b --- /dev/null +++ b/scripts/test/combo-live-vps.mjs @@ -0,0 +1,656 @@ +/** + * scripts/test/combo-live-vps.mjs + * + * Phase-3 VPS HTTP scenario driver for OmniRoute combo routing. + * Exercises 6 strategies (priority / round-robin / weighted / cost-optimized / + * fusion / auto) against the live server at 192.168.0.15:20128. + * + * Usage: + * node scripts/test/combo-live-vps.mjs + * node scripts/test/combo-live-vps.mjs --only=round-robin + * node scripts/test/combo-live-vps.mjs --failover # runs all 7 base scenarios + real failover + * + * Safety rules: + * - Only creates/deletes __live_test__* combos + * - Always cleans up in `finally` blocks + * - Never stops services or touches other data + * + * Exit code: 0 if all scenarios PASS or SKIP; non-zero only on real FAIL. + * Task 8 (--failover) can be appended after the main() call at the bottom. + */ + +import { chat, createCombo, deleteCombo, listHealthyProviders, nonce } from "./_vpsClient.mjs"; + +// --------------------------------------------------------------------------- +// Candidate list — broad to maximise coverage across volatile provider health. +// listHealthyProviders() probes each with a real chat() call. +// --------------------------------------------------------------------------- +const BROAD_CANDIDATES = [ + "groq/llama-3.1-8b-instant", + "groq/llama-3.3-70b-versatile", + "minimax/MiniMax-M3", + "minimax/minimax-m3", + "kimi-coding-apikey/moonshot-v1-8k", + "openrouter/openai/gpt-3.5-turbo", + "cerebras/llama-3.3-70b", + "cerebras/llama3.1-8b", + "deepseek/deepseek-chat", + "ollama-cloud/glm-5.2", + "glm/glm-4-flash", + "gemini/gemini-2.0-flash", +]; + +// Known approximate input cost ($/M tokens) from OmniRoute's default-pricing constants. +// Used only to identify cheap vs pricey pairs for cost-optimized scenario. +// Models absent from this map are treated as unknown cost (Infinity in the server's +// sortModelsByCost, i.e. sorted last — effectively "most expensive"). +const KNOWN_INPUT_COST = { + "groq/llama-3.1-8b-instant": 0, // inference-hosts.ts: price=0 (free tier) + "groq/llama-3.3-70b-versatile": 0, // inference-hosts.ts: price=0 (free tier) + "cerebras/llama3.1-8b": 0, // inference-hosts.ts: price=0 + "cerebras/llama-3.3-70b": 0, // inference-hosts.ts: price=0 + "deepseek/deepseek-chat": 0, // inference-hosts.ts: price=0 + "minimax/MiniMax-M3": 0.5, // regional.ts: $0.5/M input + "minimax/minimax-m3": 0.5, // regional.ts: $0.5/M input + "kimi-coding-apikey/moonshot-v1-8k": 1, // not in pricing table → Infinity on server → treated pricey +}; + +// --------------------------------------------------------------------------- +// CLI args +// --------------------------------------------------------------------------- +const onlyArg = process.argv.find((a) => a.startsWith("--only=")); +const onlyScenario = onlyArg ? onlyArg.slice(7) : null; +// --failover: opt-in flag that appends a real-failover scenario (broken primary → healthy fallback) +const failoverFlag = process.argv.includes("--failover"); + +// --------------------------------------------------------------------------- +// Result tracking +// --------------------------------------------------------------------------- +let exitCode = 0; +const summary = []; + +function pass(name, detail = "") { + summary.push({ name, result: "PASS" }); + console.log(`PASS [${name}]${detail ? ": " + detail : ""}`); +} + +function skip(name, reason) { + summary.push({ name, result: "SKIP" }); + console.log(`SKIP [${name}]: ${reason}`); +} + +function fail(name, reason, err = null) { + summary.push({ name, result: "FAIL" }); + console.error(`FAIL [${name}]: ${reason}`); + if (err) console.error(" caused by:", err?.message ?? String(err)); + exitCode = 1; +} + +async function runScenario(name, fn) { + if (onlyScenario && onlyScenario !== name) return; + try { + await fn(); + } catch (err) { + fail(name, `unexpected error: ${err?.message ?? String(err)}`, err); + } +} + +// --------------------------------------------------------------------------- +// Helper: split "provider/model" → { providerId, modelPart } +// For multi-segment paths like "openrouter/openai/gpt-3.5-turbo": +// providerId = "openrouter", modelPart = "openai/gpt-3.5-turbo" +// --------------------------------------------------------------------------- +function splitProviderModel(full) { + const idx = full.indexOf("/"); + if (idx < 0) return { providerId: full, modelPart: full }; + return { providerId: full.slice(0, idx), modelPart: full.slice(idx + 1) }; +} + +// --------------------------------------------------------------------------- +// STEP 0: Cache probe +// Verifies that a sqlite-inserted combo is immediately routable. +// getComboByName() in combos.ts does a direct DB read with no TTL cache, +// so the combo should be visible as soon as the SSH INSERT commits. +// If not (unexpected caching behaviour), we poll up to 12s before blocking. +// --------------------------------------------------------------------------- +async function step0CacheProbe(healthy) { + console.log("\n=== STEP 0: sqlite → routable cache probe ==="); + const probe = healthy[0]; + const probeName = "__live_test__probe"; + let id; + let blocked = false; + + try { + id = createCombo({ name: probeName, strategy: "priority", models: [probe] }); + console.log(` created combo id=${id} model=${probe}`); + + // Attempt immediate chat — expect instant visibility (getComboByName bypasses cache) + let r = await chat(probeName, { maxTokens: 4 }); + if (r.status === 200 && r.text) { + console.log( + ` chat() → ${r.status} model=${r.model} text="${r.text.slice(0, 40)}"` + ); + console.log(" PROBE RESULT: immediately routable — getComboByName bypasses in-memory cache"); + } else { + console.log( + ` Immediate chat → status=${r.status} text=${r.text ?? "(empty)"}` + ); + console.log(" Polling up to 12 s (TTL cache unexpectedly active)..."); + let resolved = false; + for (let i = 0; i < 6; i++) { + await new Promise((res) => setTimeout(res, 2000)); + r = await chat(probeName, { maxTokens: 4 }); + if (r.status === 200 && r.text) { + console.log(` Resolved after ~${(i + 1) * 2}s: model=${r.model}`); + console.log(" PROBE RESULT: visible after TTL expiry"); + resolved = true; + break; + } + } + if (!resolved) { + console.error(" PROBE RESULT: BLOCKER — combo not routable within 12s"); + blocked = true; + } + } + } finally { + if (id) { + try { + deleteCombo(probeName); + console.log(` cleanup: ${probeName} deleted`); + } catch (e) { + console.error(` cleanup error: ${e?.message}`); + } + } + } + + if (blocked) { + throw new Error("BLOCKER: sqlite-inserted combo not routable within 12s — cannot run scenarios"); + } +} + +// --------------------------------------------------------------------------- +// Scenario 1: priority +// Two healthy models. Single chat() call → 200 + non-empty text. +// --------------------------------------------------------------------------- +async function scenarioPriority(healthy) { + const name = "__live_test__priority"; + if (healthy.length < 1) { + skip("priority", "no healthy providers"); + return; + } + const models = healthy.slice(0, 2); + let id; + try { + id = createCombo({ name, strategy: "priority", models }); + const r = await chat(name, { maxTokens: 16 }); + if (r.status !== 200) { + fail("priority", `status=${r.status} raw=${JSON.stringify(r.raw)?.slice(0, 120)}`); + return; + } + if (!r.text) { + fail("priority", "response text is empty"); + return; + } + pass("priority", `status=200 model=${r.model} text="${r.text.slice(0, 40)}"`); + } finally { + if (id) try { deleteCombo(name); } catch { /* best effort */ } + } +} + +// --------------------------------------------------------------------------- +// Scenario 2: round-robin +// ≥2 healthy models. 5 calls with unique nonces → at least 2 distinct +// response.model values (each call must return 200). +// --------------------------------------------------------------------------- +async function scenarioRoundRobin(healthy) { + const name = "__live_test__round-robin"; + if (healthy.length < 2) { + skip("round-robin", `need ≥2 healthy providers, found ${healthy.length}`); + return; + } + const models = healthy.slice(0, Math.min(3, healthy.length)); + let id; + try { + id = createCombo({ name, strategy: "round-robin", models }); + const served = new Set(); + for (let i = 0; i < 5; i++) { + const r = await chat(name, { maxTokens: 16 }); + if (r.status !== 200) { + fail("round-robin", `call ${i + 1} returned status=${r.status}`); + return; + } + served.add(r.model); + } + if (served.size < 2) { + fail( + "round-robin", + `only 1 distinct model across 5 calls: [${[...served].join(", ")}] — round-robin not distributing` + ); + return; + } + pass("round-robin", `${served.size} distinct models across 5 calls: [${[...served].join(", ")}]`); + } finally { + if (id) try { deleteCombo(name); } catch { /* best effort */ } + } +} + +// --------------------------------------------------------------------------- +// Scenario 3: weighted +// Two healthy models with equal weight (50/50). 8 calls → both appear at +// least once (loose statistical check — P(only 1 model in 8 calls) ≈ 0.8%). +// --------------------------------------------------------------------------- +async function scenarioWeighted(healthy) { + const name = "__live_test__weighted"; + if (healthy.length < 2) { + skip("weighted", `need ≥2 healthy providers, found ${healthy.length}`); + return; + } + const [m1, m2] = healthy.slice(0, 2); + const { providerId: p1, modelPart: mp1 } = splitProviderModel(m1); + const { providerId: p2, modelPart: mp2 } = splitProviderModel(m2); + let id; + try { + id = createCombo({ + name, + strategy: "weighted", + models: [ + { providerId: p1, model: mp1, weight: 50 }, + { providerId: p2, model: mp2, weight: 50 }, + ], + }); + const tally = {}; + for (let i = 0; i < 8; i++) { + const r = await chat(name, { maxTokens: 16 }); + if (r.status !== 200) { + fail("weighted", `call ${i + 1} returned status=${r.status}`); + return; + } + tally[r.model] = (tally[r.model] ?? 0) + 1; + } + const distinct = Object.keys(tally); + if (distinct.length < 2) { + fail( + "weighted", + `only 1 distinct model across 8 calls: ${JSON.stringify(tally)} — weighted routing not distributing` + ); + return; + } + pass("weighted", `8 calls distribution: ${JSON.stringify(tally)}`); + } finally { + if (id) try { deleteCombo(name); } catch { /* best effort */ } + } +} + +// --------------------------------------------------------------------------- +// Scenario 4: cost-optimized +// Find a cheap+pricey healthy pair (based on KNOWN_INPUT_COST). +// Insert pricey first (position 0) so cost-optimized must reorder. +// Assert: the served model matches the cheap provider. +// +// OmniRoute's sortModelsByCost uses getPricingForModel which merges +// default-pricing constants. groq models have price=0; minimax M3 has $0.5. +// Cost-optimized sorts ascending → groq (0) before minimax (0.5). +// --------------------------------------------------------------------------- +async function scenarioCostOptimized(healthy) { + const name = "__live_test__cost-optimized"; + + // Partition healthy into cheap (price=0) and pricey (price>0 or unknown>0) + const cheap = healthy.filter( + (m) => KNOWN_INPUT_COST[m] !== undefined && KNOWN_INPUT_COST[m] === 0 + ); + const pricey = healthy.filter( + (m) => KNOWN_INPUT_COST[m] !== undefined && KNOWN_INPUT_COST[m] > 0 + ); + + if (cheap.length === 0 || pricey.length === 0) { + skip( + "cost-optimized", + `no distinguishable cheap+pricey pair among healthy=[${healthy.join(", ")}]` + ); + return; + } + + const cheapModel = cheap[0]; + const priceyModel = pricey[0]; + let id; + try { + // Insert pricey first — cost-optimized should reorder to serve cheapModel first + id = createCombo({ + name, + strategy: "cost-optimized", + models: [priceyModel, cheapModel], + }); + const r = await chat(name, { maxTokens: 16 }); + if (r.status !== 200) { + fail("cost-optimized", `status=${r.status}`); + return; + } + if (!r.text) { + fail("cost-optimized", "empty response text"); + return; + } + // Verify the cheap model was served (response.model contains cheap provider's model name) + const cheapProvider = splitProviderModel(cheapModel).providerId; + // Both direct match and provider-substring match are accepted since OmniRoute + // returns the raw upstream model name (e.g. "llama-3.1-8b-instant" not "groq/...") + const cheapModelPart = splitProviderModel(cheapModel).modelPart.toLowerCase(); + const servedModel = (r.model ?? "").toLowerCase(); + const isChapModel = + servedModel === cheapModelPart || + servedModel.includes(cheapModelPart) || + servedModel === cheapModel.toLowerCase() || + // fallback: check provider header if available (response.model could be bare name) + servedModel.includes(cheapProvider.toLowerCase()); + + if (!isChapModel) { + fail( + "cost-optimized", + `expected cheaper model (${cheapModel}, price=${KNOWN_INPUT_COST[cheapModel]}) but got ${r.model} — cost-optimized may not have reordered` + ); + return; + } + pass( + "cost-optimized", + `cheaper model served: ${r.model} (cheap=${cheapModel}@$${KNOWN_INPUT_COST[cheapModel]}, pricey=${priceyModel}@$${KNOWN_INPUT_COST[priceyModel]})` + ); + } finally { + if (id) try { deleteCombo(name); } catch { /* best effort */ } + } +} + +// --------------------------------------------------------------------------- +// Scenario 5: fusion +// Panel of 2-3 healthy models fan out in parallel; judge synthesizes 1 answer. +// Cost guard: panel ≤3, max_tokens=16, one call. +// --------------------------------------------------------------------------- +async function scenarioFusion(healthy) { + const name = "__live_test__fusion"; + if (healthy.length < 2) { + skip("fusion", `need ≥2 healthy providers, found ${healthy.length}`); + return; + } + // Panel: up to 3 distinct models + const panelModels = healthy.slice(0, Math.min(3, healthy.length)); + // Judge: reuse first healthy model (cheap, already warmed up) + const judgeModel = panelModels[0]; + let id; + try { + id = createCombo({ + name, + strategy: "fusion", + models: panelModels, + config: { + judgeModel, + fusionTuning: { minPanel: 2 }, + }, + }); + // Use a unique nonce in content to defeat semantic cache + const r = await chat(name, { + maxTokens: 16, + content: `hi ${nonce()} answer in one word`, + }); + if (r.status !== 200) { + fail("fusion", `status=${r.status} raw=${JSON.stringify(r.raw)?.slice(0, 120)}`); + return; + } + if (!r.text) { + fail("fusion", "judge returned empty synthesized text"); + return; + } + pass("fusion", `synthesized text="${r.text.slice(0, 60)}" served-model=${r.model}`); + } finally { + if (id) try { deleteCombo(name); } catch { /* best effort */ } + } +} + +// --------------------------------------------------------------------------- +// Scenario 6: auto +// model="auto" → virtual auto-combo bypasses DB lookup entirely. +// Assert: status=200, non-empty text, response.model is a real model (not "auto"). +// Also test "auto/fast" variant (skip if it 400s as unknown). +// --------------------------------------------------------------------------- +async function scenarioAuto() { + // 6a: bare "auto" + const r = await chat("auto", { maxTokens: 16 }); + if (r.status !== 200) { + fail("auto", `status=${r.status} raw=${JSON.stringify(r.raw)?.slice(0, 120)}`); + return; + } + if (!r.text) { + fail("auto", "empty response text"); + return; + } + const servedModel = r.model ?? ""; + if (servedModel === "auto" || !servedModel) { + fail("auto", `response.model is still "auto" — pool was not resolved`); + return; + } + pass("auto", `status=200 resolved-model=${servedModel} text="${r.text.slice(0, 40)}"`); + + // 6b: "auto/fast" variant + const r2 = await chat("auto/fast", { maxTokens: 16 }); + if (r2.status === 400 || r2.status === 404) { + skip("auto/fast", `variant not recognised (${r2.status})`); + } else if (r2.status !== 200 || !r2.text) { + fail("auto/fast", `status=${r2.status} text=${r2.text ?? "(empty)"}`); + } else { + pass("auto/fast", `status=200 model=${r2.model} text="${r2.text.slice(0, 40)}"`); + } +} + +// --------------------------------------------------------------------------- +// Scenario 7: failover (opt-in via --failover) +// +// Approach: CROSS-PROVIDER BOGUS-MODEL (deterministic, no SSH crypto required). +// +// Why cross-provider? +// A same-provider bogus-model fails silently: when target[0] (bogus) returns a +// 404, OmniRoute calls recordProviderCooldown(providerA, undefined) — a provider- +// wide key. The combo then pre-screens target[1] (same providerA, real model) and +// finds providerA in cooldown → skips it → combo returns the 404 from target[0]. +// Using DIFFERENT providers avoids this: target[0] puts providerA in cooldown, +// target[1] on providerB is unaffected, providerB serves the healthy response. +// +// Mechanism: +// - Target[0]: /__nonexistent_model_xyz__ → upstream 404 → providerA cooldown +// - Target[1]: / → not in cooldown → 200 served +// - config.maxRetries:0, retryDelayMs:0 → immediate fallover, no retry delay +// - Bogus model can never return 200 → any 200 proves fallover to the real target +// +// If only 1 distinct provider is healthy: SKIP (BROKEN-CONNECTION approach needed; see +// comment below for how to implement it using encrypted wrong API key via SSH sqlite). +// +// BROKEN-CONNECTION approach (for future reference or if cross-provider is unavailable): +// 1. SSH to read STORAGE_ENCRYPTION_KEY from /root/.omniroute/.env +// 2. Encrypt a wrong API key using scryptSync(key,"omniroute-field-encryption-v1",32)+AES-256-GCM +// 3. INSERT broken provider_connection row into provider_connections table via SSH sqlite +// 4. Combo: [{providerId:glm, model:glm/glm-4-flash, connectionId:brokenConnId}, realModel] +// 5. Broken conn → 401 → recordProviderCooldown("glm",brokenConnId) → key "glm:brokenConnId" +// 6. Target[1] on different provider → unaffected → 200 +// 7. Finally: DELETE combo AND broken connection +// --------------------------------------------------------------------------- +async function scenarioFailover(healthy) { + const name = "__live_test__failover"; + + if (healthy.length < 1) { + skip("failover", "no healthy providers — cannot run failover scenario"); + return; + } + + // Group healthy providers by providerId to find two distinct providers + const byProvider = new Map(); + for (const m of healthy) { + const { providerId } = splitProviderModel(m); + if (!byProvider.has(providerId)) byProvider.set(providerId, []); + byProvider.get(providerId).push(m); + } + const distinctProviders = [...byProvider.keys()]; + + if (distinctProviders.length < 2) { + // Cannot use cross-provider approach with only one provider. + // Same-provider bogus-model fails: 404 → recordProviderCooldown(provider, undefined) → + // provider-wide cooldown blocks target[1] on the same provider. + skip( + "failover", + `need ≥2 distinct healthy providers for cross-provider approach; ` + + `found only 1 [${distinctProviders.join(", ")}]. ` + + `Implement BROKEN-CONNECTION approach to run failover with a single provider.` + ); + return; + } + + // CROSS-PROVIDER BOGUS-MODEL: + // target[0] = /__nonexistent_model_xyz__ (will get 404, puts providerA in cooldown) + // target[1] = / (different provider, not in cooldown) + const bogusProvider = distinctProviders[0]; + const realModel = byProvider.get(distinctProviders[1])[0]; + const bogusModel = `${bogusProvider}/__nonexistent_model_xyz__`; + const { modelPart: realModelPart } = splitProviderModel(realModel); + + let id; + try { + id = createCombo({ + name, + strategy: "priority", + models: [bogusModel, realModel], + config: { maxRetries: 0, retryDelayMs: 0 }, + }); + + console.log( + ` failover combo (CROSS-PROVIDER BOGUS-MODEL):\n` + + ` [0] ${bogusModel} ← broken primary (will 404)\n` + + ` [1] ${realModel} ← healthy fallback (different provider)\n` + + ` strategy=priority, maxRetries=0` + ); + + const r = await chat(name, { maxTokens: 16 }); + + // Assertions: + // 1. status=200: the combo succeeded — ONLY possible if it fell over to target[1], + // because target[0] (bogus model) can NEVER return 200 from the upstream. + // 2. Non-empty text: real LLM content was returned (not an empty error body). + // 3. Served model is NOT the bogus one (belt-and-suspenders; 200 already proves it). + // 4. Served model matches the real healthy target (positive proof of which model served). + + if (r.status !== 200) { + fail( + "failover", + `expected status=200 after fallover (broken ${bogusModel} → ${realModel}), ` + + `got status=${r.status}. ` + + `raw=${JSON.stringify(r.raw)?.slice(0, 200)}` + ); + return; + } + + if (!r.text) { + fail("failover", `status=200 but empty text — fallover may have served a no-content response`); + return; + } + + const bogusModelPart = "__nonexistent_model_xyz__"; + const servedModel = (r.model ?? "").toLowerCase(); + + // Negative proof: bogus model did NOT serve (should never happen, but guard anyway) + if (servedModel.includes(bogusModelPart.toLowerCase())) { + fail( + "failover", + `bogus model string "${bogusModelPart}" appears in served model field "${r.model}" — ` + + `impossible 200 from a non-existent model; something is wrong` + ); + return; + } + + // Positive proof: served model matches the real healthy target + const realModelLower = realModelPart.toLowerCase(); + const servedMatchesReal = + servedModel === realModelLower || + servedModel.includes(realModelLower) || + servedModel === realModel.toLowerCase(); + + const proofNote = servedMatchesReal + ? "" + : ` [NOTE: served="${r.model}" ≠ expected="${realModelPart}" ` + + `— upstream alias likely; 200+text from bogus-primary is the failover proof]`; + + pass( + "failover", + `CROSS-PROVIDER BOGUS-MODEL: broken primary (${bogusModel}) → ` + + `fallover → served ${r.model} (real target: ${realModel}) ` + + `text="${r.text.slice(0, 40)}"${proofNote}` + ); + } finally { + if (id) { + try { + deleteCombo(name); + console.log(` cleanup: ${name} deleted`); + } catch (e) { + console.error(` cleanup error: ${e?.message}`); + } + } + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +async function main() { + console.log("=== OmniRoute combo-live-vps scenario driver ==="); + if (onlyScenario) console.log(`Filtering to scenario: ${onlyScenario}`); + console.log(); + + // --- Health probe --- + console.log("Probing healthy providers (broad candidate list)..."); + const healthy = await listHealthyProviders(BROAD_CANDIDATES); + console.log(` healthy (${healthy.length}/${BROAD_CANDIDATES.length}): [${healthy.join(", ")}]`); + + if (healthy.length === 0) { + console.error("FATAL: no healthy providers — cannot run any scenarios"); + process.exit(1); + } + + // --- STEP 0: cache probe --- + if (!onlyScenario) { + // Run STEP 0 unconditionally unless --only is passed (targeted run skips housekeeping) + try { + await step0CacheProbe(healthy); + } catch (err) { + console.error(`\nBLOCKER: ${err.message}`); + process.exit(1); + } + } + + console.log("\n=== Scenarios ===\n"); + + await runScenario("priority", () => scenarioPriority(healthy)); + await runScenario("round-robin", () => scenarioRoundRobin(healthy)); + await runScenario("weighted", () => scenarioWeighted(healthy)); + await runScenario("cost-optimized", () => scenarioCostOptimized(healthy)); + await runScenario("fusion", () => scenarioFusion(healthy)); + await runScenario("auto", () => scenarioAuto()); + + // --- Scenario 7: failover (opt-in) --- + // Only runs when --failover is passed. Uses a bogus-model primary to force a real + // combo failover to the healthy secondary, proving the priority fallover path works + // against the live server without stopping any service. + if (failoverFlag) { + console.log("\n=== Failover scenario (--failover) ===\n"); + await runScenario("failover", () => scenarioFailover(healthy)); + } + + // --- Summary --- + console.log("\n=== Summary ==="); + for (const { name, result } of summary) { + console.log(` ${result.padEnd(4)} [${name}]`); + } + const passed = summary.filter((s) => s.result === "PASS").length; + const skipped = summary.filter((s) => s.result === "SKIP").length; + const failed = summary.filter((s) => s.result === "FAIL").length; + console.log(`\n ${passed} PASS ${skipped} SKIP ${failed} FAIL`); + + process.exit(exitCode); +} + +main().catch((err) => { + console.error("FATAL:", err?.message ?? err); + process.exit(1); +}); diff --git a/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx b/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx index d6ac7bad83..0d5e87a52a 100644 --- a/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx +++ b/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx @@ -9,6 +9,7 @@ import { ROUTER_STRATEGY_OPTIONS, normalizeIntelligentRoutingConfig, } from "@/lib/combos/intelligentRouting"; +import { AI_PROVIDERS } from "@/shared/constants/providers"; import { compareTr } from "@/shared/utils/turkishText"; function getI18nOrFallback(t: any, key: string, fallback: string) { @@ -21,24 +22,34 @@ function toProviderOptions(activeProviders: any[] = [], candidatePool: string[] activeProviders.forEach((provider) => { const providerId = - typeof provider?.provider === "string" && provider.provider.trim().length > 0 - ? provider.provider - : typeof provider?.id === "string" && provider.id.trim().length > 0 - ? provider.id - : null; + typeof provider?.providerId === "string" && provider.providerId.trim().length > 0 + ? provider.providerId + : typeof provider?.provider === "string" && provider.provider.trim().length > 0 + ? provider.provider + : typeof provider?.id === "string" && provider.id.trim().length > 0 + ? provider.id + : null; if (!providerId) return; const currentEntry = uniqueProviders.get(providerId); const fallbackLabel = - typeof provider?.name === "string" && provider.name.trim().length > 0 - ? provider.name - : providerId; + typeof provider?.displayName === "string" && provider.displayName.trim().length > 0 + ? provider.displayName + : typeof provider?.providerName === "string" && provider.providerName.trim().length > 0 + ? provider.providerName + : (AI_PROVIDERS as Record)[providerId]?.name || providerId; + const connectionCount = + typeof provider?.activeConnectionCount === "number" + ? provider.activeConnectionCount + : typeof provider?.connectionCount === "number" + ? provider.connectionCount + : 1; uniqueProviders.set(providerId, { id: providerId, label: currentEntry?.label || fallbackLabel, - connectionCount: (currentEntry?.connectionCount || 0) + 1, + connectionCount: (currentEntry?.connectionCount || 0) + connectionCount, }); }); diff --git a/src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx b/src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx index 08c3875553..6063081e87 100644 --- a/src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx +++ b/src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx @@ -10,6 +10,7 @@ import { buildIntelligentProviderScores, normalizeIntelligentRoutingConfig, } from "@/lib/combos/intelligentRouting"; +import { AI_PROVIDERS } from "@/shared/constants/providers"; function getI18nOrFallback(t: any, key: string, fallback: string) { if (typeof t?.has === "function" && t.has(key)) return t(key); @@ -17,18 +18,31 @@ function getI18nOrFallback(t: any, key: string, fallback: string) { } function formatProviderLabel(providerId: string, activeProviders: any[] = []) { - const matchedConnection = activeProviders.find( - (provider) => - provider?.provider === providerId || - provider?.id === providerId || - provider?.name === providerId + const matchedProvider = activeProviders.find( + (provider) => provider?.providerId === providerId || provider?.provider === providerId ); - if (matchedConnection?.name && matchedConnection.name !== providerId) { - return `${matchedConnection.name} (${providerId})`; + if (typeof matchedProvider?.displayName === "string" && matchedProvider.displayName.trim()) { + return matchedProvider.displayName.trim(); } - return providerId; + return (AI_PROVIDERS as Record)[providerId]?.name || providerId; +} + +function countProvidersInScope(activeProviders: any[] = []) { + const providerIds = new Set(); + + for (const provider of activeProviders) { + const providerId = + typeof provider?.providerId === "string" && provider.providerId.trim().length > 0 + ? provider.providerId.trim() + : typeof provider?.provider === "string" && provider.provider.trim().length > 0 + ? provider.provider.trim() + : null; + if (providerId) providerIds.add(providerId); + } + + return providerIds.size; } export default function IntelligentComboPanel({ @@ -51,6 +65,8 @@ export default function IntelligentComboPanel({ [combo?.config] ); const providerScores = useMemo(() => buildIntelligentProviderScores(combo), [combo]); + const providerScopeCount = + normalizedConfig.candidatePool.length || countProvidersInScope(activeProviders); const handleModePackChange = async (modePackId: string) => { if (!combo?.id || modePackId === normalizedConfig.modePack) return; @@ -118,9 +134,7 @@ export default function IntelligentComboPanel({ {combo?.name} {allCombos.length} intelligent combo(s) - - {normalizedConfig.candidatePool.length || activeProviders.length} providers in scope - + {providerScopeCount} providers in scope @@ -149,9 +163,7 @@ export default function IntelligentComboPanel({

Candidate Pool

-

- {normalizedConfig.candidatePool.length || activeProviders.length} -

+

{providerScopeCount}

diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 985ad22758..350ec88e79 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -45,6 +45,7 @@ import { normalizeIntelligentRoutingFilter, normalizeIntelligentRoutingConfig, } from "@/lib/combos/intelligentRouting"; +import { resolveServerErrorMessage } from "@/lib/api/serverErrorMessage"; import { useTranslations } from "next-intl"; const ModelSelectModal = dynamic(() => import("@/shared/components/ModelSelectModal"), { @@ -54,7 +55,6 @@ const ProxyConfigModal = dynamic(() => import("@/shared/components/ProxyConfigMo ssr: false, }); -// Validate combo name: letters, numbers, spaces, -, _, /, ., [ and ]. const VALID_NAME_REGEX = /^[a-zA-Z0-9_/.\-\[\] ]+$/; const STRATEGY_OPTIONS = ROUTING_STRATEGIES.map((strategy) => ({ @@ -174,19 +174,10 @@ const ADVANCED_FIELD_HELP_FALLBACK = { }; const LEGACY_COMBO_RESILIENCE_KEYS = new Set([ - // UI-removed knobs (replaced by per-target timeoutMs on each step) "timeoutMs", "healthCheckEnabled", "healthCheckTimeoutMs", - // queueTimeoutMs is still in the schema but the dashboard UI no longer surfaces - // it; carrying it forward through edit+save leaves a stale knob in the modal - // that surprises operators. Strip it pre-PUT so the persisted config matches - // what the UI is currently able to display. "queueTimeoutMs", - // Keys that were present in v3.8.31-era combo configs but have since been - // removed from comboRuntimeConfigSchema. Mirrors the server-side strip list - // in src/app/api/combos/[id]/route.ts so the modal never re-introduces them - // when the user clicks Save. See #4382 (combo update returns 400). "queueDepth", "fallbackDelayMs", "handoffProviders", @@ -225,6 +216,19 @@ function sanitizeComboRuntimeConfig(config) { ); } +// Build the next combo config when a Fusion tuning field changes. Prunes empty / +// non-finite entries and drops the whole `fusionTuning` object when no field is +// set, so an empty `{}` is never persisted (sanitizeComboRuntimeConfig keeps any +// non-null object as-is). +function updateFusionTuning(config, field, rawValue) { + const value = rawValue === "" ? undefined : Number(rawValue); + const next = { ...(config.fusionTuning || {}), [field]: value }; + const pruned = Object.fromEntries( + Object.entries(next).filter(([, v]) => typeof v === "number" && Number.isFinite(v)) + ); + return { ...config, fusionTuning: Object.keys(pruned).length > 0 ? pruned : undefined }; +} + const STRATEGY_RECOMMENDATIONS_FALLBACK = { priority: { title: "Fail-safe baseline", @@ -554,9 +558,6 @@ function getStrategyRecommendationText(t, strategy, field) { ); } -// ───────────────────────────────────────────── -// Helper: normalize model entry (legacy string ↔ new object) -// ───────────────────────────────────────────── function normalizeModelEntry(entry) { if (typeof entry === "string") return { model: entry, weight: 0 }; if (entry?.kind === "combo-ref") { @@ -594,6 +595,24 @@ function findBuilderProviderByIdentifier(builderProviders, providerIdentifier) { ); } +function deriveCandidatePoolFromModels(models) { + const providerIds = new Set(); + + for (const entry of models || []) { + if (!entry) continue; + if (entry.kind === "combo-ref") continue; + const modelValue = getModelString(entry); + const parsed = typeof modelValue === "string" ? parseQualifiedModel(modelValue) : null; + const providerId = + typeof entry.providerId === "string" && entry.providerId.trim().length > 0 + ? entry.providerId.trim() + : parsed?.providerId; + if (providerId) providerIds.add(providerId); + } + + return Array.from(providerIds); +} + function formatComboEntryDisplay( entry, { @@ -648,9 +667,6 @@ function formatComboEntryDisplay( return `${providerLabel}/${modelLabel}`; } -// ───────────────────────────────────────────── -// Main Page -// ───────────────────────────────────────────── export default function CombosPage() { const t = useTranslations("combos"); const tc = useTranslations("common"); @@ -863,18 +879,28 @@ export default function CombosPage() { const handleToggleCombo = async (combo) => { const newActive = combo.isActive === false ? true : false; + const previousActive = combo.isActive !== false; // Optimistic update setCombos((prev) => prev.map((c) => (c.id === combo.id ? { ...c, isActive: newActive } : c))); try { - await fetch(`/api/combos/${combo.id}`, { + const res = await fetch(`/api/combos/${combo.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ isActive: newActive }), }); + if (!res.ok) { + // The server rejected the toggle (4xx/5xx). Surface its message instead + // of silently reverting with a generic toast — never swallow the error. + const errorBody = await res.json().catch(() => null); + setCombos((prev) => + prev.map((c) => (c.id === combo.id ? { ...c, isActive: previousActive } : c)) + ); + notify.error(resolveServerErrorMessage(errorBody, t("failedToggle"))); + } } catch (error) { - // Revert on error + // Revert on network error setCombos((prev) => - prev.map((c) => (c.id === combo.id ? { ...c, isActive: !newActive } : c)) + prev.map((c) => (c.id === combo.id ? { ...c, isActive: previousActive } : c)) ); notify.error(t("failedToggle")); } @@ -1125,7 +1151,6 @@ export default function CombosPage() { /> )} - {/* Combos List */} {combos.length === 0 ? ( )} - {/* Test Results Modal */} {testResults && ( )} - {/* Create Modal */} - {/* Edit Modal */} - {/* Proxy Config Modal */} {proxyTargetCombo && ( drag_indicator - {/* Icon */}
layers
- {/* Name + Strategy Badge + Copy */}
{combo.name} @@ -1672,7 +1688,6 @@ function ComboCard({
- {/* Model tags with weights */}
{models.length === 0 ? ( {t("noModels")} @@ -1701,7 +1716,6 @@ function ComboCard({ )}
- {/* Metrics row */} {metrics && (
@@ -1722,7 +1736,6 @@ function ComboCard({
- {/* Actions */}
s.emailsVisible); @@ -1907,9 +1917,6 @@ function TestResultsView({ results }) { ); } -// ───────────────────────────────────────────── -// Combo Form Modal -// ───────────────────────────────────────────── function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, comboConfigMode }) { type CreateDraftSnapshot = { name: string; @@ -2560,11 +2567,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo // identity). const handleDeselectModel = (model) => { const value = - typeof model?.value === "string" - ? model.value - : typeof model === "string" - ? model - : ""; + typeof model?.value === "string" ? model.value : typeof model === "string" ? model : ""; if (!value) return; setModels(models.filter((m) => m.model !== value)); setBuilderError(""); @@ -2761,6 +2764,16 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo if (strategy === "weighted" && config.stickyWeightedLimit !== undefined) { configToSave.stickyWeightedLimit = config.stickyWeightedLimit; } + if ( + usesIntelligentBuilderStage && + !isExpertMode && + (!Array.isArray(configToSave.candidatePool) || configToSave.candidatePool.length === 0) + ) { + const derivedCandidatePool = deriveCandidatePoolFromModels(models); + if (derivedCandidatePool.length > 0) { + configToSave.candidatePool = derivedCandidatePool; + } + } const hasConfigToSave = Object.keys(configToSave).length > 0; const hadExistingConfig = Object.keys(sanitizeComboRuntimeConfig(combo?.config)).length > 0; if (hasConfigToSave || (isEdit && hadExistingConfig)) { @@ -3073,7 +3086,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo setConfig((previousConfig) => ({ ...previousConfig, @@ -4058,6 +4071,102 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo )}
)} + {strategy === "fusion" && ( +
+
+ + + setConfig({ ...config, judgeModel: e.target.value || undefined }) + } + className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none" + /> +
+
+ + + setConfig(updateFusionTuning(config, "minPanel", e.target.value)) + } + className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none" + /> +
+
+ + + setConfig(updateFusionTuning(config, "stragglerGraceMs", e.target.value)) + } + className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none" + /> +
+
+ + + setConfig( + updateFusionTuning(config, "panelHardTimeoutMs", e.target.value) + ) + } + className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none" + /> +
+
+ )} {!isExpertMode && (

{t("advancedHint")}

)} @@ -4462,7 +4571,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
- {/* Model Select Modal */} setShowModelSelect(false)} @@ -4478,7 +4586,3 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo ); } - -// ───────────────────────────────────────────── -// WeightTotalBar moved to ./WeightTotalBar.tsx (re-exported via ./parts). -// PR-1 of diegosouzapw/OmniRoute#3932 — pure presentational component. diff --git a/src/app/(dashboard)/dashboard/compression/live/page.tsx b/src/app/(dashboard)/dashboard/compression/live/page.tsx new file mode 100644 index 0000000000..4303f30eb1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/compression/live/page.tsx @@ -0,0 +1,11 @@ +"use client"; +import { CompressionCockpit } from "../studio/CompressionCockpit"; +import { useLiveCompression } from "@/hooks/useLiveCompression"; +export default function CompressionLivePage() { + const { lastRun } = useLiveCompression(); + return ( +
+ +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx b/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx new file mode 100644 index 0000000000..df4bf66169 --- /dev/null +++ b/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx @@ -0,0 +1,137 @@ +"use client"; +import { useState } from "react"; + +interface Row { engine: string; meanSavingsPercent: number; meanRetention: number; totalCompressedTokens: number; } +interface VerifyResult { id: string; verdict: string | null; usdCost: number; skippedCapped: boolean; } +export interface CompareViewProps { text: string; } + +async function runFidelityCheck( + rows: Row[], text: string, opts: { provider: string; judgeModel: string; capUsd: number } +): Promise<{ verdicts: Record; spent: number; capped: boolean } | null> { + const items = await Promise.all( + rows.map(async (r) => { + const res = await fetch("/api/compression/preview", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: [{ role: "user", content: text }], engineId: r.engine }), + }); + const d = await res.json(); + return { id: r.engine, original: d.original ?? "", compressed: d.compressed ?? "" }; + }) + ); + const vres = await fetch("/api/compression/compare/verify", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items, provider: opts.provider, judgeModel: opts.judgeModel, costCapUsd: opts.capUsd }), + }); + const vdata = await vres.json(); + if (vres.ok && Array.isArray(vdata.results)) { + const map: Record = {}; + for (const v of vdata.results) map[v.id] = v; + return { verdicts: map, spent: typeof vdata.totalUsd === "number" ? vdata.totalUsd : 0, capped: Boolean(vdata.capped) }; + } + return null; +} + +interface VerifyControlsProps { + provider: string; onProvider: (v: string) => void; + judgeModel: string; onJudgeModel: (v: string) => void; + capUsd: number; onCapUsd: (v: number) => void; + verifying: boolean; onVerify: () => void; + spent: number | null; capped: boolean; +} + +function VerifyControls({ provider, onProvider, judgeModel, onJudgeModel, capUsd, onCapUsd, verifying, onVerify, spent, capped }: VerifyControlsProps) { + return ( + <> + + + + + {spent !== null && ( + gasto ${spent.toFixed(3)} / ${capUsd.toFixed(2)}{capped ? " · cap atingido" : ""} + )} + + ); +} + +export function CompareView({ text }: CompareViewProps) { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [verdicts, setVerdicts] = useState>({}); + const [verifying, setVerifying] = useState(false); + const [provider, setProvider] = useState("anthropic"); + const [judgeModel, setJudgeModel] = useState(""); + const [capUsd, setCapUsd] = useState(0.1); + const [spent, setSpent] = useState(null); + const [capped, setCapped] = useState(false); + + const load = async () => { + setLoading(true); + setVerdicts({}); + setSpent(null); + try { + const res = await fetch("/api/compression/compare", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: [{ role: "user", content: text }] }), + }); + const data = await res.json(); + if (res.ok) setRows(Array.isArray(data.rows) ? data.rows : []); + } finally { setLoading(false); } + }; + + const verifyAll = async () => { + if (rows.length === 0 || !judgeModel) return; + setVerifying(true); + try { + const out = await runFidelityCheck(rows, text, { provider, judgeModel, capUsd }); + if (out) { setVerdicts(out.verdicts); setSpent(out.spent); setCapped(out.capped); } + } finally { setVerifying(false); } + }; + + return ( +
+
+ + {rows.length > 0 && ( + + )} +
+ + + + + + {rows.map((r) => { + const v = verdicts[r.engine]; + return ( + + + + + + + + ); + })} + +
EngineSavingsRetençãoOut tokFidelidade
{r.engine}−{r.meanSavingsPercent.toFixed(0)}%{Math.round(r.meanRetention * 100)}%{r.totalCompressedTokens}{v ? (v.skippedCapped ? "—(cap)" : v.verdict ?? "?") : ""}
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/compression/studio/DiffPane.tsx b/src/app/(dashboard)/dashboard/compression/studio/DiffPane.tsx new file mode 100644 index 0000000000..bc3e0a0a13 --- /dev/null +++ b/src/app/(dashboard)/dashboard/compression/studio/DiffPane.tsx @@ -0,0 +1,24 @@ +"use client"; +import type { DiffSegment } from "./compressionFlowModel"; +export interface DiffPaneProps { segments: DiffSegment[]; preservedBlocks: Array<{ kind: string; preview: string }>; } +const SEG_CLASS: Record = { same: "opacity-90", removed: "bg-red-500/20 line-through", added: "bg-green-500/20" }; +export function DiffPane({ segments, preservedBlocks }: DiffPaneProps) { + return ( +
+
+ inline + +
+
+ {segments.map((seg, i) => ({seg.text}))} +
+ {preservedBlocks.length > 0 && ( +
+ {preservedBlocks.map((b, i) => ({b.kind}: {b.preview.slice(0, 40)}))} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/compression/studio/PlayView.tsx b/src/app/(dashboard)/dashboard/compression/studio/PlayView.tsx new file mode 100644 index 0000000000..7fa227f873 --- /dev/null +++ b/src/app/(dashboard)/dashboard/compression/studio/PlayView.tsx @@ -0,0 +1,69 @@ +"use client"; +import { useState } from "react"; +import { usePreviewCompression, type Lane, type PreviewBatch } from "@/hooks/usePreviewCompression"; +import { WaterfallInspector } from "./WaterfallInspector"; +import { DiffPane } from "./DiffPane"; +import { PlaygroundInput, LANE_ENGINES } from "./PlaygroundInput"; +export interface PlayViewProps { text: string; onText: (t: string) => void; laneEngines?: readonly string[]; } + +function laneStatus(l: Lane): string { + const rejected = l.run?.steps?.find((s) => s.rejected); + if (rejected) return `⚠ rejeitado: ${rejected.rejectReason ?? ""}`; + return l.error ? "⚠ erro" : l.run ? `−${l.run.savingsPercent}%` : "—"; +} + +function resolveActiveDiff(batch: PreviewBatch | null, selectedLane: string | null) { + const run = batch?.lanes.find((l) => l.engine === selectedLane)?.run ?? null; + return run?.diff ?? batch?.combined?.diff ?? null; +} + +function LaneList({ lanes, onSelect }: { lanes: Lane[]; onSelect: (e: string) => void }) { + return ( + <> + {lanes.map((l) => ( + + ))} + + ); +} + +export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewProps) { + const [active, setActive] = useState(["rtk", "caveman"]); + const [fuzzyDedup, setFuzzyDedup] = useState(false); + const [selectedLane, setSelectedLane] = useState(null); + const [fidelityGate, setFidelityGate] = useState(false); + const { batch, loading, run } = usePreviewCompression(); + const messages = [{ role: "user", content: text }]; + const toggle = (e: string) => setActive((a) => (a.includes(e) ? a.filter((x) => x !== e) : [...a, e])); + const onRun = () => run({ messages, laneEngines: [...laneEngines], activeEngines: orderByStack(active, laneEngines), fidelityGate, fuzzyDedup }); + const activeDiff = resolveActiveDiff(batch, selectedLane); + return ( +
+
+ setFidelityGate((v) => !v)} fuzzyDedup={fuzzyDedup} onToggleFuzzy={() => setFuzzyDedup((v) => !v)} /> +
+
+ {batch?.combined && ( +
+
Fluxo combinado — {active.join(" → ")}
+ +
+ )} +
+
Cada camada sozinha
+ +
+ {activeDiff && ( +
+
Diff — {selectedLane ?? "combinado"}
+ +
+ )} +
+
+ ); +} +function orderByStack(active: string[], order: readonly string[]): string[] { return order.filter((e) => active.includes(e)); } diff --git a/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx b/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx new file mode 100644 index 0000000000..e1e894824f --- /dev/null +++ b/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx @@ -0,0 +1,24 @@ +"use client"; +export const LANE_ENGINES = ["session-dedup", "ccr", "lite", "rtk", "ionizer", "headroom", "caveman", "aggressive", "ultra"] as const; +export interface PlaygroundInputProps { text: string; onText: (t: string) => void; active: string[]; onToggleActive: (engine: string) => void; onRun: () => void; loading: boolean; fidelityGate: boolean; onToggleFidelity: () => void; fuzzyDedup: boolean; onToggleFuzzy: () => void; } +export function PlaygroundInput({ text, onText, active, onToggleActive, onRun, loading, fidelityGate, onToggleFidelity, fuzzyDedup, onToggleFuzzy }: PlaygroundInputProps) { + return ( +
+