diff --git a/.env.example b/.env.example index 60b5074e6e..4a0198dc28 100644 --- a/.env.example +++ b/.env.example @@ -505,7 +505,9 @@ NEXT_PUBLIC_CLOUD_URL= #OMNIROUTE_CROF_USAGE_URL=https://crof.ai/usage_api/ #OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com #OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota -#OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit +# OpenCode Go has no public quota API — this has no default and stays +# unset unless you explicitly opt in to a self-hosted/mirrored endpoint: +#OMNIROUTE_OPENCODE_GO_QUOTA_URL= #OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace #OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings diff --git a/.env.homolog.example b/.env.homolog.example new file mode 100644 index 0000000000..03920a7707 --- /dev/null +++ b/.env.homolog.example @@ -0,0 +1,9 @@ +# Homologação E2E real — copie para .env.homolog (NUNCA commitar o real) +HOMOLOG_BASE_URL=http://192.168.0.15:20128 +# Senha de management do dashboard da VPS (a mesma do /login) +HOMOLOG_ADMIN_PASSWORD= +# Deixe vazio: a suíte cria uma API key efêmera via admin e revoga no fim. +# Só preencha para depurar uma camada isolada com uma key fixa. +HOMOLOG_API_KEY= +# Tier crítico (chat real, max_tokens=5). Demais providers: só validação de catálogo. +HOMOLOG_CRITICAL_PROVIDERS=openai,anthropic,gemini,codex,grok,glm,deepseek,openrouter diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3c641330f1..0913d78cd0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,6 +24,15 @@ updates: update-types: ["version-update:semver-major"] - dependency-name: "eslint-config-next" update-types: ["version-update:semver-major"] + # typescript majors are peer-blocked by typescript-eslint, which pins a hard + # upper bound (8.64.0 → peerDependencies.typescript ">=4.8.4 <6.1.0"). A TS 7 + # bump therefore violates the peer and takes down the whole toolchain at once — + # #7068 grouped it with 6 harmless bumps and turned Build + Lint + Quality Ratchet + # + Unit (6/8, 8/8) + Integration (1/2, 2/2) + dast-smoke red in one shot, blocking + # the innocuous updates riding along with it. Un-ignore once typescript-eslint + # widens the peer, and migrate TS majors intentionally (own PR, own CI run). + - dependency-name: "typescript" + update-types: ["version-update:semver-major"] # jscpd v5 is a Rust rewrite (native binary, no Node.js programmatic API). # scripts/check/check-duplication.mjs is deliberately pinned to jscpd@4 (it # parses jscpd-report.json against a frozen baseline). A v5 major would break diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb642d7863..d97161e470 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,7 @@ jobs: docs: ${{ steps.classify.outputs.docs }} i18n: ${{ steps.classify.outputs.i18n }} workflow: ${{ steps.classify.outputs.workflow }} + testsOnly: ${{ steps.classify.outputs.testsOnly }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: @@ -124,11 +125,23 @@ jobs: - run: npm run check:route-guard-membership - run: npm run check:test-discovery - run: npm run check:tracked-artifacts + # WS1.7 (v3.8.49 plan): Dockerfile lint (hadolint, pinned by digest). + # failure-threshold=error keeps the 5 pre-existing warnings (DL3008/DL3003/ + # DL3016 version pinning / WORKDIR) visible without blocking; any ERROR fails. + - name: hadolint (Dockerfile) + run: docker run --rm -i hadolint/hadolint@sha256:27086352fd5e1907ea2b934eb1023f217c5ae087992eb59fde121dce9c9ff21e hadolint --failure-threshold error - < Dockerfile - run: npm run check:lockfile - run: npm run check:licenses # check:docs-sync is run by the docs-sync-strict job (via check:docs-all) and the # husky pre-commit hook; the standalone copy here was redundant (ROI dedup). - run: npm run typecheck:core + # #7033: typecheck:core's curated file allowlist does not cover + # src/app/(dashboard) TSX (and next.config.mjs sets ignoreBuildErrors: + # true, so `next build` never type-checks it either) — orphaned + # identifiers there (see #6625/#6909) were invisible to CI. This gate + # runs tsc scoped to the dashboard tree against a frozen baseline of + # pre-existing errors; only NEW errors fail it. + - run: npm run check:dashboard-typecheck # typecheck:noimplicit:core dropped from this job (2026-07 optimize): # it was advisory (continue-on-error) and largely subsumed by the blocking # check:type-coverage ratchet in quality-gate. Local: npm run typecheck:noimplicit:core. @@ -148,7 +161,7 @@ jobs: # The coverage.* metrics degrade gracefully: the download is continue-on-error and # the ratchet runs with --allow-missing, so absent coverage is skipped, not failed. # Path filter: code-only — pure docs/i18n PRs have nothing for these ratchets to guard. - if: ${{ !cancelled() && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true' && (needs.lint.result == 'success' || needs.lint.result == 'failure'))) }} + if: ${{ !cancelled() && !contains(github.event.pull_request.labels.*.name, 'hotfix') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true' && (needs.lint.result == 'success' || needs.lint.result == 'failure'))) }} # security-events: read lets the CodeQL ratchet read open code-scanning alerts # via `gh api .../code-scanning/alerts`. contents: read keeps checkout working. permissions: @@ -258,7 +271,7 @@ jobs: # P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam # drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados). # Path filter: code-only (scanners/ratchets target production surface). - if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }} + if: ${{ !contains(github.event.pull_request.labels.*.name, 'hotfix') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true')) }} steps: # fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base # spec via `git show :docs/openapi.yaml`; a shallow clone @@ -506,7 +519,7 @@ jobs: with: node-version: ${{ env.CI_NODE_VERSION }} - name: Fetch base branch - run: git fetch --no-tags origin "${GITHUB_BASE_REF}" --depth=1 + run: git fetch --no-tags origin "${GITHUB_BASE_REF}" - name: Validate source changes include tests run: node scripts/check/check-pr-test-policy.mjs --summary-file .artifacts/pr-test-policy.md # Anti test-masking: flag net assert removal / new assert.ok(true) in changed tests. @@ -609,12 +622,26 @@ jobs: - name: Assert dist/server.js exists run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1) - run: npm run check:pack-artifact + # WS1.2 (#7065 class): pack the real tarball, install it into a clean prefix and + # BOOT it to a healthy /api/monitoring/health — the gate that structure checks + # cannot provide (3 releases shipped boot-crashing tarballs with green lists). + - name: Boot-smoke the packed tarball + run: npm run check:pack-boot electron-package-smoke: - name: Electron Package Smoke - runs-on: ubuntu-latest - timeout-minutes: 25 + name: Electron Package Smoke (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 needs: build + # WS1.5 (v3.8.49 plan): the Electron rebuild/spawn path previously executed for + # the FIRST time on the release tag — the v3.8.48 Windows bug (npx.cmd spawned + # without shell, CVE-2024-27980 behavior change) could only surface at release. + # windows-latest runs prepare:bundle (the ABI rebuild + spawn plan) per release + # PR; ubuntu keeps the full pack + headless smoke. + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] env: JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation CSC_IDENTITY_AUTO_DISCOVERY: "false" @@ -640,9 +667,21 @@ jobs: working-directory: electron run: npm install --no-audit --no-fund - name: Pack Electron app + if: runner.os == 'Linux' working-directory: electron run: npm run pack + # ADVISORY while the new Windows leg matures (repo convention, dast-smoke + # precedent): its first-ever real run (2026-07-15, run 29457533565) died in + # 0.7s with the error swallowed by pwsh — bash shell captures stderr and + # continue-on-error keeps the heavy gate green while we harden it (#7336). + - name: Prepare Electron standalone (Windows ABI rebuild + spawn path) + if: runner.os == 'Windows' + working-directory: electron + continue-on-error: true + shell: bash + run: npm run prepare:bundle 2>&1 - name: Smoke packaged Electron app + if: runner.os == 'Linux' env: ELECTRON_SMOKE_TIMEOUT_MS: 60000 run: xvfb-run -a npm run electron:smoke:packaged @@ -725,12 +764,23 @@ jobs: - uses: ./.github/actions/npm-ci-retry # The second test runner (CLAUDE.md: "Both test runners must pass") — was never # wired into CI until the 2026-06-09 quality audit (Fase 6A.2). - - run: npm run test:vitest - # vitest:ui is RED today (14 fails — UI component drift accumulated while the - # suite never ran in CI). Informational until the Fase 6A triage (2026-06-16+) - # fixes the components/tests; then drop continue-on-error to make it blocking. - - run: npm run test:vitest:ui + # WS5.2/5.3 (v3.8.49 plan): JUnit output feeds Trunk Flaky Tests (advisory upload + # below). node:test stays OUT of the first wave (fd1-sensitive reporter stream). + - run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-mcp.xml + # vitest:ui went back to 870/870 green in the v3.8.49 quality plan (WS6.1, + # PR #7127 — 69 fails triaged: matchMedia polyfill, node:test→vitest migration, + # CompareTab D22 cap). Promoted to BLOCKING per the plan's post-merge step. + - run: npm run test:vitest:ui -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-ui.xml + # Trunk Flaky Tests upload — advisory (never blocks), own-origin only (fork PRs + # have no TRUNK_TOKEN). Pinned by SHA (tag v2.1.2). + - name: Upload test results to Trunk (advisory) + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} continue-on-error: true + uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2 + with: + junit-paths: trunk-junit/**/*.xml + org-slug: omniroute + token: ${{ secrets.TRUNK_TOKEN }} # Node 24/26 compatibility matrices moved to .github/workflows/nightly-compat.yml # (plano mestre testes+CI, Eixo D2 — they cost ~28% of every heavy run to catch a @@ -739,9 +789,12 @@ jobs: test-coverage: name: Coverage runs-on: ubuntu-latest - timeout-minutes: 10 + # 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it); + # merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive + # release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16). + timeout-minutes: 20 needs: test-unit - if: ${{ !cancelled() && needs.test-unit.result == 'success' }} + if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }} env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long @@ -789,6 +842,7 @@ jobs: --merge-async \ --reporter=text-summary \ --reporter=json-summary \ + --reporter=lcov \ --exclude=tests/** \ --exclude=**/*.test.* \ --check-coverage \ @@ -810,6 +864,18 @@ jobs: > coverage/coverage-report.md fi cat coverage/coverage-report.md >> "$GITHUB_STEP_SUMMARY" + # WS5.6 (D7, v3.8.49 plan): patch coverage on the PR diff via Codecov — + # informational during calibration (codecov.yml sets informational: true); + # promote to blocking only after ~2 weeks without false blocks. The lcov + # reporter above also fixes coverage/lcov.info being silently absent + # (if-no-files-found: warn) — Sonar consumes the same file. + - name: Upload coverage to Codecov (informational) + if: always() + uses: codecov/codecov-action@04b047e8bb82a0c002c8312c1c880fbc6a999d45 # v5 + with: + files: coverage/lcov.info + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false - name: Upload coverage artifacts if: always() uses: actions/upload-artifact@v7 @@ -960,7 +1026,12 @@ jobs: # ~33%. Playwright browser is cached across runs (~1.5min saved per shard). # Heavy shard target: ≤20min (was ~40min). Timeout 45min to cover slow runners. timeout-minutes: 45 - needs: build + needs: [build, changes] + # WS3.1 hotfix fast-lane: the 9-shard E2E matrix is the CI critical path (~25min). + # It skips for (a) PRs labeled `hotfix` (entry policy in docs/ops/RELEASE_CHECKLIST.md: + # production-broken only, full-suite evidence from the previous green run linked in the + # PR) and (b) tests-only diffs outside tests/e2e/ (cannot change the served app). + if: ${{ needs.changes.outputs.testsOnly != 'true' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }} strategy: fail-fast: false matrix: @@ -995,7 +1066,33 @@ jobs: - name: Extract Next.js build artifact run: | tar -xzf /tmp/e2e-build.tar.gz - - run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/9 + # WS4.1: duration-balanced shards (LPT over config/quality/e2e-timings.json). + # Measured skew of plain --shard was 14× (24m47s vs 1m47s) — E2E was the CI + # critical path. The balancer self-verifies completeness and exits non-zero on + # any inconsistency, falling back to plain --shard (never fewer specs). + - name: Run E2E tests (duration-balanced shard) + env: + SHARD: ${{ matrix.shard }} + PLAYWRIGHT_JUNIT_OUTPUT_NAME: junit-e2e-results.xml + run: | + if FILES=$(node scripts/quality/balance-e2e-shards.mjs "$SHARD" 9); then + if [ -z "$FILES" ]; then echo "[e2e-balance] shard $SHARD has no files"; exit 0; fi + echo "[e2e-balance] shard $SHARD runs:"; echo "$FILES" + # shellcheck disable=SC2086 — FILES is our own newline-separated path list + npx playwright test $(echo "$FILES" | tr '\n' ' ') --reporter=line,junit + else + echo "[e2e-balance] balancer unavailable — plain --shard fallback" + npx playwright test tests/e2e/*.spec.ts --shard="$SHARD"/9 --reporter=line,junit + fi + # WS5.2/5.3: Trunk Flaky Tests upload — advisory, own-origin only, SHA-pinned. + - name: Upload test results to Trunk (advisory) + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + continue-on-error: true + uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2 + with: + junit-paths: junit-e2e-results.xml + org-slug: omniroute + token: ${{ secrets.TRUNK_TOKEN }} test-integration: name: Integration Tests (${{ matrix.shard }}/2) diff --git a/.github/workflows/dast-smoke.yml b/.github/workflows/dast-smoke.yml index 5d8676cea7..e1b5c757e5 100644 --- a/.github/workflows/dast-smoke.yml +++ b/.github/workflows/dast-smoke.yml @@ -10,7 +10,10 @@ jobs: # ADVISORY while this new gate matures (repo convention: advisory -> blocking). # Flip to blocking (remove continue-on-error) once it's proven stable across a few PRs. continue-on-error: true - timeout-minutes: 12 + # Build CLI bundle alone varies 6-11min on GitHub-hosted runners (3 consecutive + # timeouts observed on 2026-07-14 with the old 12min cap killing schemathesis + # mid-run) — 25min leaves real headroom for the actual DAST steps. + timeout-minutes: 25 env: JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa diff --git a/.github/workflows/nightly-release-green.yml b/.github/workflows/nightly-release-green.yml index 0ba8e9301d..63d1b4a054 100644 --- a/.github/workflows/nightly-release-green.yml +++ b/.github/workflows/nightly-release-green.yml @@ -1,11 +1,18 @@ -name: Nightly Release-Green +name: Release-Green (continuous) # Solution D — continuous, NON-BLOCKING drift signal for the active release branch. # # WHY: the full gate (ci.yml) only runs on the release PR (PR → main), so reds # accrue silently on release/** and explode — in layers — at release time. This -# nightly reproduces the release-equivalent validation on the active release branch -# HEAD and, when there are HARD failures, opens/updates a single tracking issue. +# workflow reproduces the release-equivalent validation on the release branch and, +# when there are HARD failures, opens/updates a single tracking issue. +# +# WS5.1 (v3.8.49 quality plan) — two modes: +# push to release/v* (code paths) → --quick (fast HARD gates, ~5-8min). Catches the +# captain's direct pushes (sync-back — the one ungated write path) AND the merged +# COMBINATION right after every PR merge, attributing the offending push range in +# the issue. Base-red MTTD drops from ≤24h to ≤~15min after the offending push. +# schedule (3×/day) → full --with-build --full-ci (the deep sweep incl. build+suites). # # It is NOT a required status check and never touches a contributor PR — it only # reports. Ratchet drift (eslint warnings / cognitive-complexity / file-size) is @@ -14,8 +21,23 @@ name: Nightly Release-Green # package-artifact) flip the issue open. on: + push: + branches: ["release/v*", "main"] + paths: + - "src/**" + - "open-sse/**" + - "bin/**" + - "electron/**" + - "scripts/**" + - "tests/**" + - "config/**" + - "package.json" + - "package-lock.json" + - "tsconfig*.json" schedule: - - cron: "23 5 * * *" # 05:23 UTC daily — off-peak, distinct from other nightlies + - cron: "23 5 * * *" # full sweep — off-peak, distinct from other nightlies + - cron: "23 12 * * *" # full sweep — midday (WS5.1: 3×/day instead of 1×) + - cron: "23 18 * * *" # full sweep — evening workflow_dispatch: inputs: branch: @@ -28,7 +50,9 @@ permissions: issues: write concurrency: - group: nightly-release-green + # push storms during merge campaigns collapse to the newest commit per branch; + # scheduled full sweeps keep their own single lane. + group: release-green-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true env: @@ -37,6 +61,9 @@ env: jobs: release-green: name: Validate active release branch + # On a push, only run for release/* pushes — a push to main is handled by the + # main-green job below. Schedule/dispatch always run (they validate the highest release). + if: ${{ github.event_name != 'push' || startsWith(github.ref_name, 'release/') }} # Dynamic runner: with USE_VPS_RUNNER=true (release window / on-demand pre-flight) # this runs on the dedicated VPS runner — clean env (no operator OMNIROUTE_API_KEY, # no local noauth CLIs => zero machine-specific false positives) and no contention. @@ -56,10 +83,15 @@ jobs: id: branch env: INPUT_BRANCH: ${{ github.event.inputs.branch }} + EVENT_NAME: ${{ github.event_name }} + PUSHED_REF: ${{ github.ref_name }} run: | set -euo pipefail if [ -n "${INPUT_BRANCH:-}" ]; then TARGET="$INPUT_BRANCH" + elif [ "$EVENT_NAME" = "push" ]; then + # validate exactly what was pushed, not the highest branch + TARGET="$PUSHED_REF" else # highest release/vX.Y.Z by semver among remote branches TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \ @@ -93,16 +125,26 @@ jobs: - name: Release-green validation (full) id: validate + env: + EVENT_NAME: ${{ github.event_name }} run: | set +e # --hermetic: scrub live-test trigger vars (self-hosted runner may carry # operator env; hosted ignores the unknown flag before #6300 lands). - # --full-ci: ALSO run every static gate from ci.yml's gate jobs (lint, - # quality-gate, quality-extended, docs-sync-strict, pr-test-policy). PRs into - # release/** only get the fast-gates, so these accrue silently and explode in - # layers on the release PR (v3.8.46: 11 static base-reds leaked). Running them - # nightly opens the tracking issue the moment one lands, not at release time. - node scripts/quality/validate-release-green.mjs --json --with-build --hermetic --full-ci \ + # push → --quick: fast HARD gates only (~5-8min), per-merge signal. + # schedule/dispatch → --with-build --full-ci: ALSO run every static gate from + # ci.yml's gate jobs (lint, quality-gate, quality-extended, docs-sync-strict, + # pr-test-policy) + build + full suites. PRs into release/** only get the + # fast-gates, so these accrue silently and explode in layers on the release PR + # (v3.8.46: 11 static base-reds leaked). + if [ "$EVENT_NAME" = "push" ]; then + MODE="--quick" + else + MODE="--with-build --full-ci" + fi + echo "[release-green] mode: $MODE (event: $EVENT_NAME)" + # shellcheck disable=SC2086 — MODE is an intentional flag list + node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \ 1> release-green.json 2> release-green.log echo "exit=$?" >> "$GITHUB_OUTPUT" echo "------- report -------" @@ -114,15 +156,28 @@ jobs: GH_TOKEN: ${{ github.token }} TARGET: ${{ steps.branch.outputs.target }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + EVENT_NAME: ${{ github.event_name }} + BEFORE_SHA: ${{ github.event.before }} + AFTER_SHA: ${{ github.event.after }} run: | set -euo pipefail TITLE="🔴 Release branch not green: ${TARGET}" { - echo "The nightly **release-green** validation found HARD failures on \`${TARGET}\`." + echo "The **release-green** validation found HARD failures on \`${TARGET}\`." echo "These are real defects that would block the release PR — fix them in the" echo "originating PR branch (via co-authorship), not by demanding it from contributors." echo "" - echo "**Run:** ${RUN_URL}" + echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})" + # WS5.1 attribution: on push events the offending change IS this push's range + # (one merge per push in the normal queue), so name it — no bisect needed. + if [ "$EVENT_NAME" = "push" ] && [ -n "${BEFORE_SHA:-}" ] && \ + git cat-file -e "$BEFORE_SHA" 2>/dev/null; then + echo "" + echo "**Offending push range** (\`${BEFORE_SHA:0:9}..${AFTER_SHA:0:9}\`):" + echo '```' + git log --no-decorate --oneline "${BEFORE_SHA}..${AFTER_SHA}" | head -20 + echo '```' + fi echo "" echo '```' sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log @@ -149,3 +204,100 @@ jobs: release-green.json release-green.log if-no-files-found: ignore + + # Companion arm for `main`. Under the parallel-cycle model, main only receives merged + # work at the release squash — so a gate/infra fix that lands only on release leaves + # main red the whole cycle, and repo-wide gates (CodeQL alert count, ratchet baselines) + # turn EVERY PR into main red on a check unrelated to its diff. This detects that and + # opens a "🔴 main not green" tracking issue. The PREVENTION is the companion-PR reflex + # (Hard Rule #21 area / _shared/merge-gates.md §8); this is the automated backstop. + main-green: + name: Validate main branch + # On a push, only run for a push to main — a push to release/* is handled by + # release-green above. Schedule/dispatch always run (they also sweep main). + if: ${{ github.event_name != 'push' || github.ref_name == 'main' }} + runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }} + env: + JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-nightly-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" + steps: + - uses: actions/checkout@v7 + with: + ref: main # literal — no injection surface; scheduled runs default to the repo default branch (a release/v*), so pin main explicitly + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + + - uses: ./.github/actions/npm-ci-retry + + - name: Main-green validation + id: validate + env: + EVENT_NAME: ${{ github.event_name }} + run: | + set +e + # push (a merge into main) → --quick fast HARD gates; schedule/dispatch → full sweep. + if [ "$EVENT_NAME" = "push" ]; then + MODE="--quick" + else + MODE="--with-build --full-ci" + fi + echo "[main-green] mode: $MODE (event: $EVENT_NAME)" + # shellcheck disable=SC2086 — MODE is an intentional flag list + node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \ + 1> main-green.json 2> main-green.log + echo "exit=$?" >> "$GITHUB_OUTPUT" + echo "------- report -------" + cat main-green.log + + - name: Open / update tracking issue on HARD failure + if: steps.validate.outputs.exit != '0' + env: + GH_TOKEN: ${{ github.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + TITLE="🔴 main branch not green" + { + echo "The **main-green** validation found HARD failures on \`main\`." + echo "" + echo "Because \`main\` only receives merged work at the release squash, a gate/infra" + echo "fix that landed only on the release branch leaves \`main\` broken for the whole" + echo "cycle — and repo-wide gates (CodeQL alert count, ratchet baselines) then turn" + echo "**every open PR into main** red on a check unrelated to its diff. The fix is a" + echo "companion PR \`--base main\` carrying the release-side fix (see" + echo "\`_shared/merge-gates.md\` §8), NOT chasing each contributor PR." + echo "" + echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})" + echo "" + echo '```' + sed -n '/──────── verdict ────────/,$p' main-green.log || tail -40 main-green.log + echo '```' + echo "" + echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) is expected mid-cycle and did NOT, on its own, open this issue._" + } > issue-body.md + + EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "") + if [ -n "$EXISTING" ]; then + gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md + echo "Updated existing issue #$EXISTING" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md + fi + + - name: Upload report artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: main-green-report + path: | + main-green.json + main-green.log + if-no-files-found: ignore diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 8e74b96d46..1edd3c09e0 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -22,6 +22,14 @@ on: - latest - next - historic + publish_mode: + description: "staged = npm stage publish (owner approves with 2FA after the staged boot-verify); direct = legacy immediate publish (emergency fallback only)" + required: false + default: "staged" + type: choice + options: + - staged + - direct workflow_call: inputs: version: @@ -166,8 +174,34 @@ jobs: TAG: ${{ github.ref_name }} run: gh release upload "$TAG" sbom-npm.cdx.json --clobber - - name: Publish to npm + # WS1.2/WS1.3 (#7065 class): the artifact that is about to be published must + # BOOT. build:cli already assembled dist/ above; this packs+installs+boots the + # real tarball and fails the publish before anything reaches the registry. + - name: Boot-smoke the tarball before ANY publish if: steps.resolve.outputs.skip != 'true' + run: npm run check:pack-boot + + # WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish` + # parks the exact bytes on the registry WITHOUT making them installable; the + # owner then verifies and approves with 2FA (`npm stage approve`), moving the + # human gate to AFTER the proof instead of before it. Requires npm >= 11.15 + # (staged publishing GA 2026-05-22). publish_mode=direct is the emergency + # fallback (legacy immediate publish) via workflow_dispatch. + - name: Ensure npm supports staged publishing + if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct') + run: | + set -euo pipefail + CUR=$(npm --version) + if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then + # Pinned exact version (supply-chain: never float @latest in the publish + # job); bump deliberately when a newer npm is required. + echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing" + npm install -g --ignore-scripts npm@11.15.0 + fi + npm --version + + - name: Publish to npm (staged — owner approves with 2FA) + if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct') env: VERSION: ${{ steps.resolve.outputs.version }} TAG: ${{ steps.resolve.outputs.tag }} @@ -175,10 +209,32 @@ jobs: run: | set -euo pipefail # Always pass --tag explicitly. Defense in depth: even if VERSION is - # accidentally an older release, `npm publish --tag historic` will - # NOT promote it to `@latest`. + # accidentally an older release, the historic tag will NOT claim `@latest`. + npm stage publish --provenance --access public --tag "$TAG" + { + echo "## 📦 omniroute@$VERSION STAGED (not yet installable)" + echo "" + echo "The exact bytes are parked on the registry. To release them:" + echo '```' + echo "npm stage list omniroute # find the stage id" + echo "npm stage approve # owner 2FA — THE publish" + echo '```' + echo "To verify the staged bytes first: npm stage download → run" + echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)." + echo "To discard: npm stage reject ." + } >> "$GITHUB_STEP_SUMMARY" + echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'" + + - name: Publish to npm (DIRECT — emergency fallback) + if: steps.resolve.outputs.skip != 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct' + env: + VERSION: ${{ steps.resolve.outputs.version }} + TAG: ${{ steps.resolve.outputs.tag }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail npm publish --provenance --access public --tag "$TAG" - echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)" + echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]" - name: Publish to GitHub Packages if: steps.resolve.outputs.skip != 'true' diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index f6cdbe4d98..e54c4ac4c2 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -62,7 +62,7 @@ jobs: docs-gates: name: Docs Gates (fast-path) needs: changes - if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }} + if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -82,7 +82,7 @@ jobs: name: Fast Quality Gates needs: changes # Code surface only — pure docs/i18n PRs skip this bag (docs-gates covers docs). - if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }} + if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }} # Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the # release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin # branches only — a fork PR must never execute on the LAN runner). Var unset/false @@ -143,6 +143,24 @@ jobs: - run: npm run check:complexity-ratchets - name: Typecheck (core) run: npm run typecheck:core + # #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not + # covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs. + - name: Typecheck (dashboard) + run: npm run check:dashboard-typecheck + # WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only. + # TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only + # arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x + # (the hybrid is the officially documented pattern). Isolated npx on purpose: + # installing an alias package could collide node_modules/.bin/tsc with 6.x. + # Promote to the blocking gate after ~1 week of parity with the step above. + - name: Typecheck (core) — TS7 native shadow (advisory) + continue-on-error: true + run: | + RC=0 + START=$(date +%s) + npx -y -p typescript@7 tsc --pretty false -p tsconfig.typecheck-core.json || RC=$? + echo "[ts7-shadow] exit=$RC elapsed=$(( $(date +%s) - START ))s — the 6.x step above stays authoritative" + exit $RC # TIA: build the impact map at runtime (gitignored, ~21MB) and run only the # unit tests impacted by this PR's changed files. On hub/unmapped changes the # selector returns __RUN_ALL__ — full-suite authority is the parallel @@ -196,7 +214,7 @@ jobs: fast-vitest: name: Vitest (fast-path) needs: changes - if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }} + if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }} # Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest). runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} env: @@ -212,12 +230,23 @@ jobs: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - - run: npm run test:vitest + # WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR, + # which is where flaky-detection volume actually comes from (ci.yml's heavy + # jobs only run on the release PR). Advisory upload, own-origin only. + - run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml + - name: Upload test results to Trunk (advisory) + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + continue-on-error: true + uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2 + with: + junit-paths: trunk-junit/**/*.xml + org-slug: omniroute + token: ${{ secrets.TRUNK_TOKEN }} fast-unit: name: Unit Tests fast-path (${{ matrix.shard }}/4) needs: changes - if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }} + if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }} # Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest). # This is the heaviest fast-path job; 4-way sharding (was 2, #6781) halves the # critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot @@ -263,7 +292,7 @@ jobs: lint-guard: name: No new ESLint warnings needs: changes - if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }} + if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }} runs-on: ubuntu-latest continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }} steps: @@ -302,7 +331,7 @@ jobs: merge-integrity: name: Merge integrity (changelog + generated skills) # Always on non-draft PRs — CHANGELOG/skills can break on docs-only merges too. - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) }} runs-on: ubuntu-latest continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }} env: diff --git a/.gitignore b/.gitignore index 67d69b3d5d..b7406dbfca 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* !.env.example +!.env.homolog.example # Provider API keys (never commit) *.api-key .nvidia-api-key @@ -242,3 +243,9 @@ _artifacts/ # CI/local quality artifacts (eslint-results.json, etc.) .artifacts/ + +# Homologation E2E suite (npm run homolog) — real-environment credentials + report output +.env.homolog +tests/homolog/.auth/ +tests/homolog/ui/.auth/ +homolog-report/ diff --git a/.gitleaks.toml b/.gitleaks.toml index 0051e694b2..8b9978a454 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -74,3 +74,16 @@ # '''tests/unit/''', # ] # + +[[rules]] + # Falsos-positivos comprovados do generic-api-key — zerados em 2026-07-13 (WS6/D3, + # plano v3.8.49). Revisar em v3.9.0. Nenhum é credencial: dois são NOMES DE CAMPO + # de métricas de latência; o terceiro é o valor PÚBLICO de um beta header da API + # da Anthropic (documentado publicamente, não é segredo). + id = "generic-api-key" + [rules.allowlist] + description = "Field names + public Anthropic beta-header value (não são segredos)" + regexes = [ + '''latencyP\d{2}Ms''', + '''interleaved-thinking-2025-05-14''', + ] diff --git a/.mergify.yml b/.mergify.yml index 7cc4882127..131c6d71a9 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -17,13 +17,6 @@ # • Fallback path if Mergify misbehaves or the OSS plan changes: the manual # merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand. -# Auto-enqueue (current Mergify model, 2026): auto_merge_conditions in -# merge_protections_settings — the rules-based queue action / autoqueue path is -# deprecated (EOL 2026-07-16). The owner-applied `queue` label IS the approval. -merge_protections_settings: - auto_merge_conditions: - - label = queue - queue_rules: - name: release # Any current or future release branch — the reason GitHub's native queue was @@ -41,20 +34,14 @@ queue_rules: # is intentionally NOT a condition here: the owner-applied `queue` label IS the # approval in this repo's single-maintainer model (see governance header). merge_conditions: - # "Zero failures" — EXCEPT the advisory dast-smoke: it is continue-on-error by - # design and its GH-hosted CLI build hangs recurrently (drafts #7184/#7221 were - # dequeued solely by it). Any OTHER failure still blocks (anti-fail-open kept). - - or: - - "#check-failure=0" - - and: - - "#check-failure=1" - - check-failure=dast-smoke + - "#check-failure=0" - "#check-pending=0" - "#check-success>=1" - check-success=Merge integrity (changelog + generated skills) - # NO batching: 'Merge Queue Batch' requires a paid Mergify tier (live finding - # 2026-07-15 — the queue command fails with "Cannot use Merge Queue batch" on - # the free plan). Serial queue (1 PR at a time) still automates the train. + # Batching: validate up to 10 queued PRs together (the manual train's sweet spot); + # don't hold a lone PR hostage waiting for siblings. + batch_size: 10 + batch_max_wait_time: 5 min # Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects. merge_method: squash diff --git a/CHANGELOG.md b/CHANGELOG.md index 090e756d91..3ab0f12866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ --- +## [3.8.49] — TBD + +--- + ## [3.8.48] — 2026-07-13 > ⚠️ **Hotfix release.** The published npm package for 3.8.47 crashed on every boot ([#7065](https://github.com/diegosouzapw/OmniRoute/issues/7065)) and was deprecated — **3.8.48 is the first installable release of the v3.8.47 cycle**, so everything listed under [3.8.47] below ships here. diff --git a/Dockerfile b/Dockerfile index adf3cf5e91..98e0a3215b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -114,6 +114,12 @@ LABEL org.opencontainers.image.title="omniroute" \ ENV NODE_ENV=production ENV PORT=20128 ENV HOSTNAME=0.0.0.0 +# Runtime heap ceiling. 1024MB is enough for normal traffic but can be tight +# for large fusion-combo panels (many models fanned out in parallel, each +# response buffered in full — see open-sse/services/fusion.ts::FUSION_DEFAULTS +# .maxPanel, issue #1905). Override at `docker run` time with +# `-e OMNIROUTE_MEMORY_MB=2048` (or higher) if you raise fusionTuning.maxPanel +# above the default cap. ENV OMNIROUTE_MEMORY_MB=1024 ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}" diff --git a/bin/cli/runtime/nativeDeps.mjs b/bin/cli/runtime/nativeDeps.mjs index 2a0787bd04..1dc442270e 100644 --- a/bin/cli/runtime/nativeDeps.mjs +++ b/bin/cli/runtime/nativeDeps.mjs @@ -52,6 +52,31 @@ export function hasModule(name) { return existsSync(join(runtimeModules(), name, "package.json")); } +/** + * Probe whether a native addon (.node) file can actually be dlopen'd by the Node runtime that + * is going to load it. Runs in a throwaway subprocess so a real ABI mismatch (which can segfault + * the process instead of throwing) never takes down the caller — only the probe subprocess. + */ +function probeNativeBinaryLoadable(binary) { + try { + const res = spawnSync( + process.execPath, + [ + "-e", + "try { require(process.argv[1]); process.exit(0); } catch (e) { process.exit(1); }", + binary, + ], + { timeout: 10_000, stdio: "ignore" } + ); + // status === 0 means require() (and therefore dlopen) succeeded. Anything else — a thrown + // ERR_DLOPEN_FAILED/NODE_MODULE_VERSION mismatch (status 1) or a crash (status null with a + // signal, e.g. SIGSEGV) — means the binary is not safe to load. + return res.status === 0; + } catch { + return false; + } +} + export function isBetterSqliteBinaryValid() { const binary = join( runtimeModules(), @@ -68,10 +93,18 @@ export function isBetterSqliteBinaryValid() { closeSync(fd); const magic = buf.toString("hex"); const os = platform(); - if (os === "linux") return magic.startsWith("7f454c46"); // ELF - if (os === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O - if (os === "win32") return magic.startsWith("4d5a"); // PE/MZ - return true; + let formatOk; + if (os === "linux") formatOk = magic.startsWith("7f454c46"); // ELF + else if (os === "darwin") + formatOk = magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O + else if (os === "win32") formatOk = magic.startsWith("4d5a"); // PE/MZ + else formatOk = true; + if (!formatOk) return false; + // File-format magic bytes alone do not guarantee the binary was built for the Node ABI + // (NODE_MODULE_VERSION) that will load it — a stale/foreign-ABI binary passes the header + // check and then crashes (segfault) on load instead of triggering a rebuild. Actually + // attempt to load it, isolated in a subprocess. + return probeNativeBinaryLoadable(binary); } catch { return false; } diff --git a/changelog.d/features/6072-ws-server-inprocess-autostart.md b/changelog.d/features/6072-ws-server-inprocess-autostart.md new file mode 100644 index 0000000000..b5b2aa5277 --- /dev/null +++ b/changelog.d/features/6072-ws-server-inprocess-autostart.md @@ -0,0 +1 @@ +- **feat(ws):** the live-dashboard WebSocket server now auto-starts in-process (via `instrumentation-node.ts`) across every deployment mode — dev, production, Docker, Electron — with no separate sidecar script; the default WS port moved from 20129 to **20132** to avoid colliding with `API_PORT` in split-port setups, the deprecated `OMNIROUTE_DISABLE_LIVE_WS` env was consolidated into `OMNIROUTE_ENABLE_LIVE_WS` (default enabled), and the WS path is now derived from `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL`'s pathname (`/live-ws` fallback) (#6072 — thanks @ianriizky). diff --git a/changelog.d/features/6556-omniglyph-engine.md b/changelog.d/features/6556-omniglyph-engine.md new file mode 100644 index 0000000000..5417e469e5 --- /dev/null +++ b/changelog.d/features/6556-omniglyph-engine.md @@ -0,0 +1 @@ +- **feat(compression):** new **omniglyph** engine (context-as-image) — renders system prompt, tool docs, and dense history as compact PNG pages the model reads instead of text (~10× fewer tokens on the converted block; 59–70% end-to-end measured). Works stacked with RTK/Caveman (`stackPriority: 90`) or standalone (`mode: omniglyph`); restricted to Claude Fable 5 over the direct Anthropic route, fail-closed gates with `skip:` techniques, preview (`stable: false`, off by default) (#6556). Dependency bumped to `omniglyph@^1.0.2` for upstream ReDoS fixes (#6661). diff --git a/changelog.d/features/6611-native-container-runtimes.md b/changelog.d/features/6611-native-container-runtimes.md new file mode 100644 index 0000000000..3515d9dc04 --- /dev/null +++ b/changelog.d/features/6611-native-container-runtimes.md @@ -0,0 +1 @@ +- **feat(sandbox):** the skill sandbox gained a container-provider abstraction that auto-detects and uses the best native runtime per host — Apple Container (macOS 26+), WSL container (`wslc.exe`), OrbStack, Podman — instead of hardcoding `docker run`, removing the Docker Desktop requirement on macOS/Windows (#6611 — thanks @KooshaPari). diff --git a/changelog.d/features/6708-gemma4-thinkingconfig-guard.md b/changelog.d/features/6708-gemma4-thinkingconfig-guard.md new file mode 100644 index 0000000000..74d34a131d --- /dev/null +++ b/changelog.d/features/6708-gemma4-thinkingconfig-guard.md @@ -0,0 +1 @@ +- **fix(sse):** skip `thinkingConfig` for Gemma models on the OpenAI→Gemini path so OpenAI-shape clients no longer get a 400 from Vertex. (thanks @chy1211) diff --git a/changelog.d/features/6709-xai-responses-endpoint.md b/changelog.d/features/6709-xai-responses-endpoint.md new file mode 100644 index 0000000000..d828e6a7b8 --- /dev/null +++ b/changelog.d/features/6709-xai-responses-endpoint.md @@ -0,0 +1 @@ +- **feat(xai):** route xAI clients to Grok's native `/v1/responses` endpoint instead of the chat-completions bridge. (thanks @ryanngit) diff --git a/changelog.d/features/6727-model-capability-override-ui.md b/changelog.d/features/6727-model-capability-override-ui.md new file mode 100644 index 0000000000..77d2d01ac6 --- /dev/null +++ b/changelog.d/features/6727-model-capability-override-ui.md @@ -0,0 +1 @@ +- **feat(models):** add a Settings → AI "Model Overrides" UI plus `/api/model-capability-overrides` CRUD and a `model_capability_overrides` table, letting operators set a manual max-output-token override per provider/model (#6727 — thanks @xz-dev). diff --git a/changelog.d/features/6763-operator-configurable-account-rotation.md b/changelog.d/features/6763-operator-configurable-account-rotation.md new file mode 100644 index 0000000000..71ff263a3a --- /dev/null +++ b/changelog.d/features/6763-operator-configurable-account-rotation.md @@ -0,0 +1 @@ +- **feat(resilience):** operator-configurable account rotation policy — a new `rotationConfig` layer lets operators tune how connections rotate on failure, wired into `accountFallback` (#6763 — thanks @artickc). diff --git a/changelog.d/features/6774-cursor-grok-4-5-effort-fast.md b/changelog.d/features/6774-cursor-grok-4-5-effort-fast.md new file mode 100644 index 0000000000..a43c55469b --- /dev/null +++ b/changelog.d/features/6774-cursor-grok-4-5-effort-fast.md @@ -0,0 +1 @@ +- **chore(cursor): add Grok 4.5 effort/fast model IDs** (#6774 — thanks @andrewmunsell). diff --git a/changelog.d/features/6776-discover-live-codex-models.md b/changelog.d/features/6776-discover-live-codex-models.md new file mode 100644 index 0000000000..111b674056 --- /dev/null +++ b/changelog.d/features/6776-discover-live-codex-models.md @@ -0,0 +1 @@ +- **feat(codex):** Codex provider model discovery now fetches the live catalog from `chatgpt.com/backend-api/codex/models` using Codex-shaped headers, falling back to a GitHub-hosted model manifest and then to the local static catalog when the live/GitHub sources are unavailable or return an unexpected shape — new `src/app/api/providers/[id]/models/discovery/codex.ts` (normalization, version-gating, merge/enrich against the local catalog) covered by `tests/unit/provider-models-discovery-split.test.ts` and `tests/unit/provider-models-route-codex.test.ts` (#6776 — thanks @JxnLexn). diff --git a/changelog.d/features/6779-cursor-opus48-fable5-sonnet5.md b/changelog.d/features/6779-cursor-opus48-fable5-sonnet5.md new file mode 100644 index 0000000000..f1b893ab09 --- /dev/null +++ b/changelog.d/features/6779-cursor-opus48-fable5-sonnet5.md @@ -0,0 +1 @@ +- **feat(cursor):** register the Opus 4.8, Fable 5, and Sonnet 5 model families for the Cursor Agent provider so the latest Claude/Fable model ids route correctly (#6779 — thanks @andrewmunsell). diff --git a/changelog.d/features/6783-changelog-fragments.md b/changelog.d/features/6783-changelog-fragments.md new file mode 100644 index 0000000000..0980c2967a --- /dev/null +++ b/changelog.d/features/6783-changelog-fragments.md @@ -0,0 +1 @@ +- **Changelog fragments (`changelog.d/`)**: PRs now add their changelog entry as a new fragment file (`changelog.d/{features|fixes|maintenance}/-.md`) instead of editing `CHANGELOG.md` — two PRs never touch the same file, structurally eliminating the CHANGELOG-eat merge conflicts that forced a re-sync push + full CI re-run after every sibling merge (O(N²) CI runs in a merge-storm). `scripts/release/aggregate-changelog.mjs` (npm run changelog:aggregate) folds fragments into the living section at release reconciliation, and `check:changelog-integrity` now also validates fragment well-formedness. Regression guard: `tests/unit/changelog-fragments.test.ts`. diff --git a/changelog.d/features/6798-latency-optimized-proxy-rotation.md b/changelog.d/features/6798-latency-optimized-proxy-rotation.md new file mode 100644 index 0000000000..e15583b2ec --- /dev/null +++ b/changelog.d/features/6798-latency-optimized-proxy-rotation.md @@ -0,0 +1 @@ +- **feat(proxy):** add a latency-optimized proxy rotation strategy that ranks pool entries by measured round-trip latency, extending the existing round-robin/random/sticky proxy-pool selection (#6798 — thanks @iamraydoan). diff --git a/changelog.d/features/6804-fusion-judge-own-knowledge.md b/changelog.d/features/6804-fusion-judge-own-knowledge.md new file mode 100644 index 0000000000..7577033596 --- /dev/null +++ b/changelog.d/features/6804-fusion-judge-own-knowledge.md @@ -0,0 +1 @@ +- **feat(fusion):** the fusion judge may now draw on its own knowledge and override the panel when every panel answer is wrong or incomplete, instead of being restricted to synthesizing only from panel output (#6804 — thanks @chirag127). diff --git a/changelog.d/features/6811-playground-model-picker-search.md b/changelog.d/features/6811-playground-model-picker-search.md new file mode 100644 index 0000000000..2022587e21 --- /dev/null +++ b/changelog.d/features/6811-playground-model-picker-search.md @@ -0,0 +1 @@ +- **feat(dashboard):** search box on the Playground's raw model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. -- **feat(usage):** Antigravity/agy quota widget now surfaces the **weekly** window alongside the existing per-model 5-hour window ([#4017](https://github.com/diegosouzapw/OmniRoute/issues/4017)) — the weekly limit isn't part of the per-model `retrieveUserQuota` response the fetcher already calls; it only appears in a separate, undocumented `retrieveUserQuotaSummary` RPC that groups models into families ("Gemini Models", "Claude and GPT models") with one weekly bucket per family. A new `usage/antigravityWeeklyQuota.ts` leaf fetches that RPC (cached, best-effort — a failure or unavailable RPC never breaks the existing per-model quotas) and parses the weekly bucket per group into `gemini_weekly`/`claude_gpt_weekly` quota entries, merged into the same `quotas` map the widget already renders generically. Regression guard: `tests/unit/antigravity-weekly-quota-4017.test.ts` (bucket parsing, the alternate `quotaSummary`-nested envelope, and end-to-end merge via `getUsageForProvider`). -- **feat(codex):** Codex CLI compatibility shim — the Responses API `response.created`/`response.in_progress`/`response.completed` payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested, effort-suffixed model id (e.g. `gpt-5.5-xhigh`) instead of the bare upstream id (`gpt-5.5`), so the Codex CLI status line/model button shows the active reasoning effort ([#3697](https://github.com/diegosouzapw/OmniRoute/issues/3697)). `openaiToOpenAIResponsesResponse` (`open-sse/translator/response/openai-responses.ts`) now threads the upstream model into the Responses event objects; a new `isCodexOriginatedHeaders()` (`open-sse/config/codexIdentity.ts`, reusing PR #3481's `originator`/User-Agent detection) makes chatCore's existing opt-in `echoRequestedModelName` (#1311) model-echo pipeline fire automatically for Codex clients regardless of the setting, detected by request headers so it still applies when `codex/gpt-5.5-xhigh` is routed through a combo to a non-codex upstream; `echoModelInObject`/`echoModelInSseLine` (`open-sse/services/responseModelEcho.ts`) now also rewrite the nested `response.model` field the Responses API uses. `/v1/models` still returns `models: []` for Codex (unchanged). Regression guard: `tests/unit/codex-effort-model-echo-3697.test.ts`. -- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) -- **feat(compression):** update the vendored **GCF** codec behind the **Headroom** engine to spec **v3.2 (nested flattening)** ([#6837](https://github.com/diegosouzapw/OmniRoute/issues/6837)). Homogeneous arrays whose rows carry nested objects/arrays now tabularize via `>`-prefixed path fields instead of a low-yield per-row fallback, so nested MCP tool-result rows (`meta:{...}`, `tags:[...]`) compact like flat rows. On representative shapes the update takes deeply-nested payloads the old codec left near-uncompressed from ~3% to ~32% vs JSON (k8s pods, `cl100k_base`), with shallow-nested rows seeing a small bump and flat arrays unchanged. Re-vendored from current gcf-typescript (zero runtime deps, MIT, SPDX-marked, generic-profile only); also folds in the `[N]:` inline-array quoting fix and canonical decimal formatting. Round-trip stays lossless (order-insensitive), and the decoder is hardened against prototype pollution (a `__proto__`/`constructor` path segment never mutates `Object.prototype`, and keys shadowing built-ins like `toString` now round-trip correctly instead of misparsing). Regression guard: `tests/unit/compression/headroom-smartcrusher.test.ts` (deep-nested + prototype-pollution cases). -- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun -- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun - -### 🐛 Bug Fixes - -- **fix(dashboard):** the Proxy Registry settings page crashed at runtime (`ReferenceError: poolLoaded/bulkImportOpen is not defined`) — the #6625/#6909 hook-extraction refactors deleted 10 state declarations (`poolLoaded`, `poolSaving`, and the 8-member bulk-import family) while ~30 usages remained; all restored (caught by the release E2E; `typecheck:core` does not cover dashboard TSX — follow-up #7021). (thanks @diegosouzapw) - -- **fix(combo):** `comboStickyRoundRobinLimit` now defaults to inherit (`null`) instead of `1` — the literal default silently shadowed the documented batched round-robin rotation (`stickyRoundRobinLimit: 3`), flipping every round-robin combo to per-request alternation (#6678 follow-up, caught by the release CI). (thanks @diegosouzapw) -- **fix(ws):** the standalone LiveWS startup script exited 0 without ever listening — its bootstrapped child re-spawned with the import-suppressor `OMNIROUTE_ENABLE_LIVE_WS=0` and then honored it as an operator disable (#6072 follow-up, caught by the release CI). (thanks @diegosouzapw) - -- **fix(api):** compression PUT schema accepts every catalog engine. ([#6792](https://github.com/diegosouzapw/OmniRoute/pull/6792) — thanks @Pitchfork-and-Torch) -- **fix(compression):** surface fallback reasons in the preview response. ([#6461](https://github.com/diegosouzapw/OmniRoute/issues/6461), [#6519](https://github.com/diegosouzapw/OmniRoute/pull/6519) — thanks @chirag127) -- **fix(providers):** fail fast on an empty auto-combo pool instead of a 15s timeout. ([#6458](https://github.com/diegosouzapw/OmniRoute/issues/6458), [#6546](https://github.com/diegosouzapw/OmniRoute/pull/6546) — thanks @chirag127) -- **fix(compression):** honor UI-toggled engines in the stackedPipeline dispatch + surface substitutions. ([#6463](https://github.com/diegosouzapw/OmniRoute/issues/6463), [#6534](https://github.com/diegosouzapw/OmniRoute/pull/6534) — thanks @chirag127) -- **fix(api):** return 400 for missing/invalid `messages` before model resolution. ([#6402](https://github.com/diegosouzapw/OmniRoute/issues/6402), [#6515](https://github.com/diegosouzapw/OmniRoute/pull/6515) — thanks @chirag127) -- **fix(providers):** enrich the model_cooldown 429 body with a `retry_after` ISO timestamp + credential count. ([#6460](https://github.com/diegosouzapw/OmniRoute/issues/6460), [#6523](https://github.com/diegosouzapw/OmniRoute/pull/6523) — thanks @chirag127) - -- **fix(sse):** default reasoning summary for effort-only Responses requests. ([#6807](https://github.com/diegosouzapw/OmniRoute/pull/6807) — thanks @rushsinging) -- **fix(sse):** compression no-op treated as zero-savings, not inflation/silent-drop. ([#6883](https://github.com/diegosouzapw/OmniRoute/pull/6883) — thanks @chirag127) -- **fix(oauth):** Trae OAuth client_id embedded via `resolvePublicCred()` (Hard Rule #11). ([#6870](https://github.com/diegosouzapw/OmniRoute/pull/6870) — thanks @chirag127) -- **fix(sse):** combo path no longer trips the whole-provider breaker on a plain 429. ([#6868](https://github.com/diegosouzapw/OmniRoute/pull/6868) — thanks @chirag127) -- **fix(api):** malformed JSON bodies now return 400 instead of 500. ([#6871](https://github.com/diegosouzapw/OmniRoute/pull/6871) — thanks @chirag127) -- **fix(fusion):** judge selected from a surviving panel member when no explicit judge is configured. ([#6869](https://github.com/diegosouzapw/OmniRoute/pull/6869) — thanks @chirag127) -- **fix(sse):** combo model lockout honors the parsed upstream quota reset. ([#6863](https://github.com/diegosouzapw/OmniRoute/issues/6863), [#6866](https://github.com/diegosouzapw/OmniRoute/pull/6866) — thanks @AgentKiller45) -- **fix(dashboard):** logs detail modal no longer reopens on first close. ([#6830](https://github.com/diegosouzapw/OmniRoute/pull/6830) — thanks @MikeTuev) -- **fix(usage):** honor xAI provider-reported exact cost. ([#6711](https://github.com/diegosouzapw/OmniRoute/pull/6711) — thanks @diegosouzapw) -- **fix(kiro):** probe IdC region during profileArn discovery, cross-region (recovers #6099). ([#6840](https://github.com/diegosouzapw/OmniRoute/pull/6840) — thanks @diegosouzapw) -- **fix(antigravity):** sanitize Cloud Code safety settings. ([#6839](https://github.com/diegosouzapw/OmniRoute/pull/6839) — thanks @diegosouzapw) -- **fix(translator):** defer `content_block_start` until GLM streams the tool name. ([#6730](https://github.com/diegosouzapw/OmniRoute/pull/6730) — thanks @diegosouzapw) -- **fix(translator):** strip empty `cloud_base_branch` from Cursor Subagent tool calls. ([#6729](https://github.com/diegosouzapw/OmniRoute/pull/6729) — thanks @diegosouzapw) -- **fix(antigravity):** surface aborted Gemini tool calls off `end_turn`. ([#6713](https://github.com/diegosouzapw/OmniRoute/pull/6713) — thanks @diegosouzapw) -- **fix(volcengine):** clamp Kimi `max_tokens` to the Ark endpoint cap. ([#6712](https://github.com/diegosouzapw/OmniRoute/pull/6712) — thanks @diegosouzapw) -- **fix(codex):** surface capacity errors embedded in 200-OK SSE streams. ([#6710](https://github.com/diegosouzapw/OmniRoute/pull/6710) — thanks @diegosouzapw) -- **fix(sse):** skip `thinkingConfig` for gemma models in openai→gemini translation. ([#6708](https://github.com/diegosouzapw/OmniRoute/pull/6708) — thanks @diegosouzapw) -- **fix(oauth):** avoid bare-email dedup of Codex OAuth logins. ([#6706](https://github.com/diegosouzapw/OmniRoute/pull/6706) — thanks @diegosouzapw) -- **fix(sse):** unwrap bare `{function:{…}}` tools in openai→claude translation. ([#6704](https://github.com/diegosouzapw/OmniRoute/pull/6704) — thanks @diegosouzapw) -- **fix(db):** eliminate a redundant `getApiKeyMetadata` call in the embeddings route. ([#6929](https://github.com/diegosouzapw/OmniRoute/pull/6929) — thanks @oyi77) -- **fix(db):** `authType` filter support in `getProviderConnections`. ([#6946](https://github.com/diegosouzapw/OmniRoute/pull/6946) — thanks @oyi77) -- **fix(i18n):** the provider-detail (`/dashboard/providers/[id]`) visibility + free/paid model filter labels (`showVisibleOnly`, `showHiddenOnly`, `freeFilterAll`, `freeFilterFreeOnly`, `freeFilterPaidOnly`, `hideAllModels`, plus the currently-unused `filterVisible`/`filterHidden`/`filterByVisibility`) rendered as the literal `__MISSING__:` sentinel in 15 locales, including pt-BR ([#6694](https://github.com/diegosouzapw/OmniRoute/issues/6694)) — `providerText()` (`providerPageHelpers.ts`) checks `t.has(key)` before falling back to clean English, and `t.has()` returns `true` even when the stored value is the `__MISSING__:` sentinel `scripts/i18n/sync-ui-keys.mjs` writes when mirroring keys across locales, so the sentinel rendered verbatim instead of the fallback. Disjoint key set from #6290 (`filterAll`/`filterActive`/`filterError`/`filterBanned`/`filterCreditsExhausted`). All 9 keys now carry real translations across the 15 affected locale files (`it`, `ja`, `ko`, `mr`, `ms`, `nl`, `no`, `phi`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `sk`, `sv`). Regression guard: `tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts`. - -- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. -- **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) -- **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) -- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. -- **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. -- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) -- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). -- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. -- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur) -- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) -- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127) -- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) -- **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) -- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. -- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. -- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model ` setNewSupportsVision(e.target.checked)} + className="rounded border-border" + /> + {`👁️ ${t("visionCapableLabel")}`} + + @@ -482,6 +511,14 @@ export default function CustomModelsSection({ {`🪟 ${model.contextWindowOverride.toLocaleString()}`} )} + {model.supportsVision === true && ( + + {`👁️ ${t("visionCapableLabel")}`} + + )} {model.supportedEndpoints?.includes("embeddings") && ( {`📐 ${t("supportedEndpointEmbeddings")}`} @@ -578,6 +615,23 @@ export default function CustomModelsSection({ className="w-full px-2.5 py-2 text-xs border border-border rounded-lg bg-background text-text-main focus:outline-none focus:border-primary" /> +
+ + +
{t("supportedEndpointsLabel")} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerCredentialText.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerCredentialText.ts new file mode 100644 index 0000000000..6c2c20026d --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerCredentialText.ts @@ -0,0 +1,132 @@ +// Pure, shared helpers for provider credential copy (labels/hints/titles for +// the API-key and web-session-credential modals). Extracted out of +// providerPageHelpers.ts (Issue #3501 strangler-fig decomposition) — that leaf +// is frozen at its file-size cap, so this cohesive slice (message-translation +// utility + the 4 web-session-credential text builders) lives here instead and +// is re-exported from providerPageHelpers.ts for backward compatibility. Leaf +// module — imports only from @/shared, @/lib and colocated sibling modules. +import { type WebSessionCredentialRequirement } from "./webSessionCredentials"; + +export type ProviderMessageTranslator = (( + key: string, + values?: Record +) => string) & { + has?: (key: string) => boolean; +}; + +export function providerText( + t: ProviderMessageTranslator, + key: string, + fallback: string, + values?: Record +): string { + if (typeof t.has === "function" && t.has(key)) { + return t(key, values); + } + if (values) { + return Object.entries(values).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + fallback + ); + } + return fallback; +} + +export function getWebSessionCredentialLabel( + t: ProviderMessageTranslator, + requirement: WebSessionCredentialRequirement, + optional: boolean +): string { + if (requirement.kind === "none") { + return providerText(t, "webNoAuthCredentialLabel", "No credential required"); + } + const baseLabel = + requirement.kind === "token" + ? providerText(t, "webTokenCredentialLabel", "Web session token") + : t("sessionCookieLabel"); + return optional ? `${baseLabel} (${t("optional").toLowerCase()})` : baseLabel; +} + +export function getWebSessionCredentialHint( + t: ProviderMessageTranslator, + requirement: WebSessionCredentialRequirement, + providerName: string, + editing: boolean +): string | undefined { + if (requirement.kind === "none") return undefined; + + const values = { provider: providerName, credential: requirement.credentialName }; + if (editing) { + return requirement.kind === "token" + ? providerText( + t, + "webTokenEditHint", + "Leave blank to keep the current web session token. Credential: {credential}.", + values + ) + : providerText( + t, + "webCookieEditHint", + "Leave blank to keep the current session cookie. Required cookie: {credential}.", + values + ); + } + + // #5465 — a provider-specific hint (e.g. t3.chat's step-by-step DevTools copy) + // replaces the generic one-line cookie/token template when that template is + // unclear for the provider (t3.chat needs a localStorage value AND the Cookie + // header, so "Required cookie: convex-session-id + Cookie header…" reads + // circular). The override key ships translated in every locale. + if (requirement.hintKey) { + return providerText( + t, + requirement.hintKey, + requirement.hintFallback ?? + "Open the provider's web session in DevTools, copy the required credential(s), and paste them in the fields below.", + values + ); + } + + return requirement.kind === "token" + ? providerText( + t, + "webTokenCredentialHint", + "Credential: {credential}. Paste the token value from your own signed-in {provider} web session, or a DevTools HAR export if the provider supports it.", + values + ) + : providerText( + t, + "webCookieCredentialHint", + "Required cookie: {credential}. Paste the Cookie header value from your own signed-in {provider} web session. Do not include the Cookie: prefix.", + values + ); +} + +export function getWebSessionCredentialCheckLabel( + t: ProviderMessageTranslator, + requirement: WebSessionCredentialRequirement +): string { + if (requirement.kind === "token") return providerText(t, "checkWebToken", "Check token"); + return providerText(t, "checkCookie", "Check cookie"); +} + +export function getAddCredentialModalTitle( + t: ProviderMessageTranslator, + providerName: string, + requirement: WebSessionCredentialRequirement | null +): string { + if (!requirement) return t("addProviderApiKeyTitle", { provider: providerName }); + if (requirement.kind === "none") { + return providerText(t, "addProviderConnectionTitle", "Add {provider} connection", { + provider: providerName, + }); + } + if (requirement.kind === "token") { + return providerText(t, "addProviderWebTokenTitle", "Add {provider} web token", { + provider: providerName, + }); + } + return providerText(t, "addProviderSessionCookieTitle", "Add {provider} session cookie", { + provider: providerName, + }); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index 4345d11962..22a25dc59f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -16,20 +16,32 @@ import { type CodexServiceTier, } from "@/lib/providers/requestDefaults"; import { type CodexGlobalServiceMode } from "@/lib/providers/codexFastTier"; -import { type WebSessionCredentialRequirement } from "./webSessionCredentials"; import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "./providerDetailConstants"; +import { + type ProviderMessageTranslator, + providerText, + getWebSessionCredentialLabel, + getWebSessionCredentialHint, + getWebSessionCredentialCheckLabel, + getAddCredentialModalTitle, +} from "./providerCredentialText"; + +// Re-exported for backward compatibility — these used to be defined here +// (Issue #3501 strangler-fig home), but were extracted to providerCredentialText.ts +// once this leaf hit its frozen file-size cap (#1904 own growth). +export { + type ProviderMessageTranslator, + providerText, + getWebSessionCredentialLabel, + getWebSessionCredentialHint, + getWebSessionCredentialCheckLabel, + getAddCredentialModalTitle, +}; // --------------------------------------------------------------------------- // Types shared between page + modals // --------------------------------------------------------------------------- -export type ProviderMessageTranslator = (( - key: string, - values?: Record -) => string) & { - has?: (key: string) => boolean; -}; - export type LocalProviderMetadata = { name?: string; localDefault?: string; @@ -76,6 +88,9 @@ export type CompatModelRow = { compatByProtocol?: CompatByProtocolMap; /** #2905: per-model upstream wire-format override. */ targetFormat?: string; /** #4125: manual context-window override (tokens), when set. */ contextWindowOverride?: number; + /** #1904: manual vision-capability override for custom models whose upstream + * discovery metadata doesn't self-report an image input modality. */ + supportsVision?: boolean; }; export type CompatModelMap = Map; @@ -98,28 +113,6 @@ export function targetFormatBadgeI18nKey(value: string): string | null { return TARGET_FORMAT_BADGE_I18N_KEYS[value] ?? null; } -// --------------------------------------------------------------------------- -// Utility — message translation with fallback -// --------------------------------------------------------------------------- - -export function providerText( - t: ProviderMessageTranslator, - key: string, - fallback: string, - values?: Record -): string { - if (typeof t.has === "function" && t.has(key)) { - return t(key, values); - } - if (values) { - return Object.entries(values).reduce( - (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), - fallback - ); - } - return fallback; -} - /** #5442 — badge for add-credential validation; unsupported → neutral N/A (not red Invalid). */ export function validationBadgeProps(result: string): { variant: "success" | "error" | "info"; @@ -385,108 +378,10 @@ export function formatExcludedModelsInput(value: unknown): string { } // --------------------------------------------------------------------------- -// Web-session credential label / hint helpers (Phase 2b) +// Web-session credential label / hint helpers (Phase 2b) — moved to +// providerCredentialText.ts (#1904 own growth); re-exported above. // --------------------------------------------------------------------------- -export function getWebSessionCredentialLabel( - t: ProviderMessageTranslator, - requirement: WebSessionCredentialRequirement, - optional: boolean -): string { - if (requirement.kind === "none") { - return providerText(t, "webNoAuthCredentialLabel", "No credential required"); - } - const baseLabel = - requirement.kind === "token" - ? providerText(t, "webTokenCredentialLabel", "Web session token") - : t("sessionCookieLabel"); - return optional ? `${baseLabel} (${t("optional").toLowerCase()})` : baseLabel; -} - -export function getWebSessionCredentialHint( - t: ProviderMessageTranslator, - requirement: WebSessionCredentialRequirement, - providerName: string, - editing: boolean -): string | undefined { - if (requirement.kind === "none") return undefined; - - const values = { provider: providerName, credential: requirement.credentialName }; - if (editing) { - return requirement.kind === "token" - ? providerText( - t, - "webTokenEditHint", - "Leave blank to keep the current web session token. Credential: {credential}.", - values - ) - : providerText( - t, - "webCookieEditHint", - "Leave blank to keep the current session cookie. Required cookie: {credential}.", - values - ); - } - - // #5465 — a provider-specific hint (e.g. t3.chat's step-by-step DevTools copy) - // replaces the generic one-line cookie/token template when that template is - // unclear for the provider (t3.chat needs a localStorage value AND the Cookie - // header, so "Required cookie: convex-session-id + Cookie header…" reads - // circular). The override key ships translated in every locale. - if (requirement.hintKey) { - return providerText( - t, - requirement.hintKey, - requirement.hintFallback ?? - "Open the provider's web session in DevTools, copy the required credential(s), and paste them in the fields below.", - values - ); - } - - return requirement.kind === "token" - ? providerText( - t, - "webTokenCredentialHint", - "Credential: {credential}. Paste the token value from your own signed-in {provider} web session, or a DevTools HAR export if the provider supports it.", - values - ) - : providerText( - t, - "webCookieCredentialHint", - "Required cookie: {credential}. Paste the Cookie header value from your own signed-in {provider} web session. Do not include the Cookie: prefix.", - values - ); -} - -export function getWebSessionCredentialCheckLabel( - t: ProviderMessageTranslator, - requirement: WebSessionCredentialRequirement -): string { - if (requirement.kind === "token") return providerText(t, "checkWebToken", "Check token"); - return providerText(t, "checkCookie", "Check cookie"); -} - -export function getAddCredentialModalTitle( - t: ProviderMessageTranslator, - providerName: string, - requirement: WebSessionCredentialRequirement | null -): string { - if (!requirement) return t("addProviderApiKeyTitle", { provider: providerName }); - if (requirement.kind === "none") { - return providerText(t, "addProviderConnectionTitle", "Add {provider} connection", { - provider: providerName, - }); - } - if (requirement.kind === "token") { - return providerText(t, "addProviderWebTokenTitle", "Add {provider} web token", { - provider: providerName, - }); - } - return providerText(t, "addProviderSessionCookieTitle", "Add {provider} session cookie", { - provider: providerName, - }); -} - // --------------------------------------------------------------------------- // Upstream-headers helpers (Phase 2b) // --------------------------------------------------------------------------- diff --git a/src/app/(dashboard)/dashboard/providers/services/components/CliproxyProviderExposureCard.tsx b/src/app/(dashboard)/dashboard/providers/services/components/CliproxyProviderExposureCard.tsx new file mode 100644 index 0000000000..d3e406747a --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/services/components/CliproxyProviderExposureCard.tsx @@ -0,0 +1,89 @@ +/** + * Provider Exposure toggle for CLIProxyAPI. + * Persists the `providerExpose` field via POST /api/services/cliproxy/provider-expose. + * When enabled, CLIProxyAPI models appear as `cliproxyapi/...` in OmniRoute's model selection. + */ +"use client"; + +import { useState } from "react"; +import { Card, Toggle } from "@/shared/components"; +import { useServiceStatus } from "../hooks/useServiceStatus"; + +const NAME = "cliproxy"; + +export function CliproxyProviderExposureCard() { + const { data, mutate } = useServiceStatus(NAME); + const [pending, setPending] = useState(false); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + + async function handleToggle(enabled: boolean) { + setPending(true); + setMsg(null); + try { + const res = await fetch(`/api/services/${NAME}/provider-expose`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const errorMsg = + body?.error?.message ?? body?.message ?? `Failed to update (HTTP ${res.status})`; + setMsg({ ok: false, text: errorMsg }); + return; + } + setMsg(null); + mutate(); + } catch { + setMsg({ ok: false, text: "Network error — could not update provider exposure setting" }); + } finally { + setPending(false); + } + } + + return ( + +
+
+ hub +
+
+

Provider Exposure

+

+ Expose CLIProxyAPI models as a routing target under the{" "} + cliproxyapi/ prefix. +

+
+
+ + {msg && ( +
+ + {msg.ok ? "check_circle" : "error"} + + {msg.text} +
+ )} + +
+
+

+ Expose as{" "} + cliproxyapi/... +

+

+ When enabled, discovered models appear in provider selects across OmniRoute. +

+
+ +
+
+ ); +} + diff --git a/src/app/(dashboard)/dashboard/providers/services/tabs/CliproxyServiceTab.tsx b/src/app/(dashboard)/dashboard/providers/services/tabs/CliproxyServiceTab.tsx index 6d7245e21a..a25686f292 100644 --- a/src/app/(dashboard)/dashboard/providers/services/tabs/CliproxyServiceTab.tsx +++ b/src/app/(dashboard)/dashboard/providers/services/tabs/CliproxyServiceTab.tsx @@ -6,6 +6,7 @@ import { ServiceLogsPanel } from "../components/ServiceLogsPanel"; import { CliproxyModelMappingEditor } from "../components/CliproxyModelMappingEditor"; import { AutoStartToggle } from "../components/AutoStartToggle"; import { CliproxyConnectionPanel } from "../components/CliproxyConnectionPanel"; +import { CliproxyProviderExposureCard } from "../components/CliproxyProviderExposureCard"; const NAME = "cliproxy"; @@ -19,6 +20,7 @@ export function CliproxyServiceTab() { description="Launch CLIProxyAPI automatically when OmniRoute starts" /> +
diff --git a/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx b/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx index a9165a7684..8b702028cd 100644 --- a/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx +++ b/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx @@ -5,6 +5,8 @@ import { useTranslations } from "next-intl"; import Link from "next/link"; import type { SearchProviderCatalogItem } from "@/shared/schemas/searchTools"; +const MAX_COMPARE_PROVIDERS = 4; // D22: cap at 4 providers running in parallel + export interface CompareResult { provider: string; latency: number; @@ -69,12 +71,15 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) { const toggleProvider = useCallback((id: string) => { setSelectedProviderIds((prev) => { if (prev.includes(id)) return prev.filter((p) => p !== id); + if (prev.length >= MAX_COMPARE_PROVIDERS) return prev; return [...prev, id]; }); }, []); const selectAll = useCallback(() => { - setSelectedProviderIds(activeSearchProviders.map((p) => p.id)); + setSelectedProviderIds( + activeSearchProviders.slice(0, MAX_COMPARE_PROVIDERS).map((p) => p.id) + ); }, [activeSearchProviders]); const clearAll = useCallback(() => { @@ -230,7 +235,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) { - {/* Provider picker — no cap */} + {/* Provider picker — capped at MAX_COMPARE_PROVIDERS (D22) */}

@@ -253,9 +258,15 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {

+ {selectedProviderIds.length >= MAX_COMPARE_PROVIDERS && ( +

+ Maximum of {MAX_COMPARE_PROVIDERS} providers can be compared at once. +

+ )}
{activeSearchProviders.map((p) => { const selected = selectedProviderIds.includes(p.id); + const atCap = !selected && selectedProviderIds.length >= MAX_COMPARE_PROVIDERS; return ( . +setup("autentica e salva storageState", async ({ page }) => { + await page.goto("/login"); + await page.locator('input[type="password"]').fill(process.env.HOMOLOG_ADMIN_PASSWORD!); + await page.locator('button[type="submit"]').click(); + await page.waitForURL(/\/dashboard/); + await expect(page).toHaveURL(/dashboard/); + await page.context().storageState({ path: STORAGE_STATE }); +}); diff --git a/tests/homolog/ui/playwright.config.ts b/tests/homolog/ui/playwright.config.ts new file mode 100644 index 0000000000..78e099debe --- /dev/null +++ b/tests/homolog/ui/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig } from "@playwright/test"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const STORAGE_STATE = path.join(HERE, ".auth", "admin.json"); + +export default defineConfig({ + testDir: ".", + timeout: 60_000, + retries: 1, + // Sem fullyParallel, os 98 testes de routes.spec.ts (mesmo arquivo) rodam + // SERIALIZADOS num único worker (~10min); com ele, distribuem entre os workers. + fullyParallel: true, + workers: 8, + reporter: [ + ["list"], + [ + // outputDir ABSOLUTO: o reporter resolve paths relativos contra o CWD do + // processo (não contra o config) — um path relativo escapava do worktree. + "playwright-ctrf-json-reporter", + { + outputDir: path.resolve(HERE, "..", "..", "..", "homolog-report"), + outputFile: "ui-ctrf.json", + }, + ], + ], + use: { + baseURL: process.env.HOMOLOG_BASE_URL || "http://192.168.0.15:20128", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { name: "setup", testMatch: /auth\.setup\.ts/ }, + { + name: "homolog", + testMatch: /.*\.spec\.ts/, + dependencies: ["setup"], + use: { storageState: STORAGE_STATE }, + }, + ], +}); diff --git a/tests/homolog/ui/routes.spec.ts b/tests/homolog/ui/routes.spec.ts new file mode 100644 index 0000000000..febc68749c --- /dev/null +++ b/tests/homolog/ui/routes.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// Descobre as rotas estáticas do dashboard a partir do próprio repo: +// cada page.tsx sob src/app/(dashboard)/dashboard vira uma rota; grupos (x) somem +// do path e rotas dinâmicas [param] são puladas (sem dado real garantido). +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const BASE = path.join(ROOT, "src", "app", "(dashboard)", "dashboard"); + +function discoverRoutes(dir: string, prefix = "/dashboard"): string[] { + const routes: string[] = []; + if (fs.existsSync(path.join(dir, "page.tsx"))) routes.push(prefix || "/"); + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (!e.isDirectory() || e.name.startsWith("[") || e.name.startsWith("_")) continue; + const seg = e.name.startsWith("(") ? "" : `/${e.name}`; + routes.push(...discoverRoutes(path.join(dir, e.name), `${prefix}${seg}`)); + } + return [...new Set(routes)]; +} + +for (const route of discoverRoutes(BASE)) { + test(`rota ${route} carrega sem crash`, async ({ page }) => { + const pageErrors: string[] = []; + page.on("pageerror", (err) => pageErrors.push(err.message)); + + const res = await page.goto(route, { waitUntil: "domcontentloaded" }); + expect(res!.status(), `HTTP em ${route}`).toBeLessThan(400); + // "networkidle" nunca assenta em telas com polling/websocket ao vivo (30s x 98 rotas + // estourava o run inteiro) — "load" + um settle curto e suficiente para hidratar e + // deixar um crash de client component (pageerror / error boundary) aparecer. + await page.waitForLoadState("load", { timeout: 10_000 }).catch(() => {}); + await page.waitForTimeout(1_500); + + // Error boundary do Next: nunca pode aparecer + await expect(page.locator("text=Application error")).toHaveCount(0); + expect(pageErrors, `pageerror em ${route}: ${pageErrors.join(" | ")}`).toHaveLength(0); + }); +} diff --git a/tests/integration/agent-skills-content.test.ts b/tests/integration/agent-skills-content.test.ts index 9e0ad388ab..74eed7741b 100644 --- a/tests/integration/agent-skills-content.test.ts +++ b/tests/integration/agent-skills-content.test.ts @@ -2,9 +2,9 @@ * Integration tests for Agent Skills content integrity. * * Verifies: - * 1. All 43 skill IDs from catalog have skills/{id}/ folder with SKILL.md. + * 1. All 44 skill IDs from catalog have skills/{id}/ folder with SKILL.md. * 2. Zero omniroute-* folders remain (post-prune: old omniroute-* skill dirs were removed). - * 3. 10 specific IDs have ... blocks: + * 3. 12 specific IDs have ... blocks: * omni-mcp, omni-compression, cli-providers, cli-eval, omni-agents-a2a, * omni-combos-routing, omni-auth, omni-resilience, omni-inference, cli-serve. * @@ -22,6 +22,7 @@ const ALL_IDS = [...API_SKILL_IDS, ...CLI_SKILL_IDS, ...CONFIG_SKILL_IDS] as str // IDs that must have a custom block const CUSTOM_BLOCK_IDS = [ + "cli-skill-collector", "omni-mcp", "omni-compression", "cli-providers", @@ -37,7 +38,7 @@ const CUSTOM_BLOCK_IDS = [ // ── §1: All 42 catalog IDs have skills/{id}/SKILL.md ───────────────────────── -test("all 43 catalog IDs have a skills/{id}/ directory", () => { +test("all 44 catalog IDs have a skills/{id}/ directory", () => { const missing: string[] = []; for (const id of ALL_IDS) { const dirPath = path.join(SKILLS_DIR, id); @@ -48,7 +49,7 @@ test("all 43 catalog IDs have a skills/{id}/ directory", () => { assert.deepEqual(missing, [], `Missing skill directories: ${missing.join(", ")}`); }); -test("all 43 catalog IDs have a skills/{id}/SKILL.md file", () => { +test("all 44 catalog IDs have a skills/{id}/SKILL.md file", () => { const missing: string[] = []; for (const id of ALL_IDS) { const skillPath = path.join(SKILLS_DIR, id, "SKILL.md"); @@ -113,7 +114,7 @@ for (const id of CUSTOM_BLOCK_IDS) { // ── Additional integrity checks ─────────────────────────────────────────────── -test("exactly 11 skills have custom blocks", () => { +test("exactly 12 skills have custom blocks", () => { const withCustomBlocks: string[] = []; for (const id of ALL_IDS) { const skillPath = path.join(SKILLS_DIR, id, "SKILL.md"); @@ -128,7 +129,7 @@ test("exactly 11 skills have custom blocks", () => { assert.deepEqual( withCustomBlocks.sort(), expectedIds, - `Expected exactly these 11 custom-block IDs: ${expectedIds.join(", ")}\nActual: ${withCustomBlocks.join(", ")}`, + `Expected exactly these 12 custom-block IDs: ${expectedIds.join(", ")}\nActual: ${withCustomBlocks.join(", ")}`, ); }); diff --git a/tests/integration/agent-skills-discovery.test.ts b/tests/integration/agent-skills-discovery.test.ts index 9e80ff4d18..6cc9a8dc52 100644 --- a/tests/integration/agent-skills-discovery.test.ts +++ b/tests/integration/agent-skills-discovery.test.ts @@ -69,8 +69,8 @@ test("every CLI skill ID has skills//SKILL.md on disk", () => { assert.deepEqual(missing, [], `Missing CLI SKILL.md files: ${missing.join(", ")}`); }); -test("total skill count is exactly 43 (23 API + 20 CLI)", () => { - assert.equal(API_SKILL_IDS.length + CLI_SKILL_IDS.length, 43); +test("total skill count is exactly 44 (23 API + 21 CLI)", () => { + assert.equal(API_SKILL_IDS.length + CLI_SKILL_IDS.length, 44); }); // ── §2: Frontmatter validation ──────────────────────────────────────────────── @@ -120,11 +120,11 @@ test("each SKILL.md body is at least 100 chars", () => { // ── §3: MCP tool omniroute_agent_skills_list ───────────────────────────────── -test("MCP omniroute_agent_skills_list handler returns count 44 (43 + config)", async () => { +test("MCP omniroute_agent_skills_list handler returns count 45 (44 + config)", async () => { const result = await agentSkillTools.omniroute_agent_skills_list.handler({}); - assert.equal(result.count, 44, `Expected 44 but got ${result.count}`); + assert.equal(result.count, 45, `Expected 45 but got ${result.count}`); assert.ok(Array.isArray(result.skills)); - assert.equal(result.skills.length, 44); + assert.equal(result.skills.length, 45); }); test("MCP omniroute_agent_skills_list result has all 42 IDs", async () => { @@ -157,9 +157,9 @@ test("A2A list-capabilities artifact content contains 42 skill IDs as table rows assert.ok(rows.length >= 42, `Expected at least 42 data rows but got ${rows.length}`); }); -test("A2A list-capabilities metadata.totalSkills === 44 (43 + config)", async () => { +test("A2A list-capabilities metadata.totalSkills === 45 (44 + config)", async () => { const result = await executeListCapabilities(stubTask); - assert.equal(result.metadata.totalSkills, 44); + assert.equal(result.metadata.totalSkills, 45); }); test("A2A list-capabilities artifact contains all 42 skill IDs", async () => { diff --git a/tests/integration/codex-chat-reasoning-http-e2e.test.ts b/tests/integration/codex-chat-reasoning-http-e2e.test.ts new file mode 100644 index 0000000000..e5a03749ff --- /dev/null +++ b/tests/integration/codex-chat-reasoning-http-e2e.test.ts @@ -0,0 +1,303 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { once } from "node:events"; + +const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"; +const ENCRYPTED_CONTENT_SENTINEL = "encrypted-codex-state:" + "A".repeat(910); +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-chat-http-")); + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +process.env.API_KEY_SECRET = "codex-chat-http-e2e-secret-123456"; +process.env.REQUIRE_API_KEY = "false"; +process.env.OMNIROUTE_LOG_REQUEST_SHAPE = "0"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts"); + +const originalFetch = globalThis.fetch; + +type RecordedRequest = { + url: string; + method: string; + body: Record; +}; + +function responsesEvents() { + const response = { + id: "resp_reasoning_http", + object: "response", + status: "in_progress", + model: "gpt-5.6-sol", + output: [], + }; + return [ + { type: "response.created", response }, + { + type: "response.output_item.added", + output_index: 0, + item: { + id: "rs_reasoning_http", + type: "reasoning", + encrypted_content: ENCRYPTED_CONTENT_SENTINEL, + summary: [], + }, + }, + { + type: "response.output_item.done", + output_index: 0, + item: { + id: "rs_reasoning_http", + type: "reasoning", + encrypted_content: ENCRYPTED_CONTENT_SENTINEL, + summary: [], + }, + }, + { + type: "response.output_item.added", + output_index: 1, + item: { id: "msg_reasoning_http", type: "message", role: "assistant", content: [] }, + }, + { + type: "response.content_part.added", + item_id: "msg_reasoning_http", + output_index: 1, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }, + { + type: "response.output_text.delta", + item_id: "msg_reasoning_http", + output_index: 1, + content_index: 0, + delta: "The answer is 42.", + }, + { + type: "response.output_text.done", + item_id: "msg_reasoning_http", + output_index: 1, + content_index: 0, + text: "The answer is 42.", + }, + { + type: "response.output_item.done", + output_index: 1, + item: { + id: "msg_reasoning_http", + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "The answer is 42.", annotations: [] }], + }, + }, + { + type: "response.completed", + response: { + ...response, + status: "completed", + output: [ + { + id: "rs_reasoning_http", + type: "reasoning", + summary: [{ type: "summary_text", text: "I checked the contract. " }], + }, + { + id: "msg_reasoning_http", + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "The answer is 42.", annotations: [] }], + }, + ], + usage: { input_tokens: 8, output_tokens: 9, total_tokens: 17 }, + }, + }, + ]; +} + +function mockResponsesSse() { + const nativeFraming = process.env.CODEX_NATIVE_EVENT_FRAMING === "1"; + return responsesEvents() + .map((event) => { + const eventLine = nativeFraming ? `event: ${event.type}\n` : ""; + return `${eventLine}data: ${JSON.stringify(event)}\n\n`; + }) + .join(""); +} + +async function readIncomingBody(request: http.IncomingMessage) { + const chunks: Buffer[] = []; + for await (const chunk of request) + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks); +} + +async function bridgeRouteResponse(response: Response, outgoing: http.ServerResponse) { + outgoing.writeHead(response.status, Object.fromEntries(response.headers.entries())); + if (!response.body) { + outgoing.end(); + return; + } + + const reader = response.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!outgoing.write(value)) await once(outgoing, "drain"); + } + outgoing.end(); + } finally { + reader.releaseLock(); + } +} + +async function startRouteServer() { + const server = http.createServer(async (incoming, outgoing) => { + try { + if (incoming.method !== "POST" || incoming.url !== "/v1/chat/completions") { + outgoing.writeHead(404).end(); + return; + } + + const body = await readIncomingBody(incoming); + const address = server.address(); + assert(address && typeof address !== "string"); + const headers = new Headers(); + for (const [name, value] of Object.entries(incoming.headers)) { + if (Array.isArray(value)) value.forEach((item) => headers.append(name, item)); + else if (value !== undefined) headers.set(name, value); + } + const request = new Request(`http://127.0.0.1:${address.port}${incoming.url}`, { + method: incoming.method, + headers, + body, + }); + await bridgeRouteResponse(await chatRoute.POST(request), outgoing); + } catch (error) { + // Mock route bridge: surface the message, never the raw stack (js/stack-trace-exposure). + outgoing.writeHead(500, { "content-type": "text/plain" }); + outgoing.end(error instanceof Error ? error.message : String(error)); + } + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert(address && typeof address !== "string"); + return { server, url: `http://127.0.0.1:${address.port}/v1/chat/completions` }; +} + +function parseSse(raw: string) { + return raw + .split(/\n\n+/) + .map((block) => + block + .split("\n") + .find((line) => line.startsWith("data: ")) + ?.slice(6) + ) + .filter((data): data is string => Boolean(data)); +} + +async function closeServer(server: http.Server) { + if (!server.listening) return; + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); +} + +test("chat completions streams Codex Responses reasoning through real route HTTP", async () => { + const recorded: RecordedRequest[] = []; + let routeServer: http.Server | undefined; + + try { + await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-http-reasoning", + email: "codex-http@example.test", + accessToken: "mock-codex-access-token", + refreshToken: "mock-codex-refresh-token", + tokenType: "Bearer", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + + const routeHarness = await startRouteServer(); + routeServer = routeHarness.server; + + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.url !== CODEX_RESPONSES_URL) { + throw new Error(`Unexpected external fetch in Codex HTTP test: ${request.url}`); + } + recorded.push({ + url: request.url, + method: request.method, + body: JSON.parse(await request.text()) as Record, + }); + return new Response(mockResponsesSse(), { + status: 200, + headers: { "content-type": "text/event-stream; charset=utf-8" }, + }); + }; + + const response = await originalFetch(routeHarness.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "codex/gpt-5.6-sol", + stream: true, + reasoning_effort: "high", + messages: [{ role: "user", content: "What is the answer?" }], + }), + }); + const raw = await response.text(); + + assert.equal(response.status, 200, raw); + assert.match(response.headers.get("content-type") ?? "", /^text\/event-stream/); + assert.equal(recorded.length, 1); + assert.equal(recorded[0].url, CODEX_RESPONSES_URL); + assert.equal(recorded[0].method, "POST"); + assert.deepEqual(recorded[0].body.reasoning, { effort: "high", summary: "auto" }); + assert.deepEqual(recorded[0].body.input, [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "What is the answer?" }], + }, + ]); + + const chunks = parseSse(raw); + assert.equal(chunks.at(-1), "[DONE]"); + const payloads = chunks.slice(0, -1).map((chunk) => JSON.parse(chunk)); + const reasoningContentDeltas = payloads + .map((payload) => payload.choices?.[0]?.delta?.reasoning_content) + .filter((content): content is string => Boolean(content)); + assert.equal(reasoningContentDeltas.length, 1); + const reasoningContent = reasoningContentDeltas.join(""); + assert.match(reasoningContent, /encrypted (?:state|private reasoning)/i); + assert(!raw.includes(ENCRYPTED_CONTENT_SENTINEL), raw); + assert(!reasoningContent.includes(ENCRYPTED_CONTENT_SENTINEL), reasoningContent); + assert( + payloads.some((payload) => payload.choices?.[0]?.delta?.content === "The answer is 42.") + ); + assert(!raw.includes("response.reasoning_summary_text.delta"), raw); + assert(!raw.includes('"type":"error"'), raw); + assert(!raw.includes('"error"'), raw); + } finally { + globalThis.fetch = originalFetch; + if (routeServer) await closeServer(routeServer); + core.closeDbInstance({ checkpointMode: null }); + await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/agent-bridge-dns-toggle-method-7157.test.ts b/tests/unit/agent-bridge-dns-toggle-method-7157.test.ts new file mode 100644 index 0000000000..53a7c4311a --- /dev/null +++ b/tests/unit/agent-bridge-dns-toggle-method-7157.test.ts @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const clientPath = path.resolve( + __dirname, + "../../src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx" +); +const source = readFileSync(clientPath, "utf8"); + +test("#7157: dns toggle fetch call uses method POST (route.ts only exports POST)", () => { + const dnsCallMatch = source.match( + /\/api\/tools\/agent-bridge\/agents\/\$\{agentId\}\/dns`,\s*\{\s*method:\s*"([A-Z]+)"/ + ); + assert.ok(dnsCallMatch, "expected to find the dns fetch call in AgentBridgePageClient.tsx"); + assert.equal( + dnsCallMatch?.[1], + "POST", + "dns fetch call must use method: 'POST' to match the route.ts export (issue #7157)" + ); +}); diff --git a/tests/unit/agentSkillTools-mcp.test.ts b/tests/unit/agentSkillTools-mcp.test.ts index a34acea595..e344f14928 100644 --- a/tests/unit/agentSkillTools-mcp.test.ts +++ b/tests/unit/agentSkillTools-mcp.test.ts @@ -58,11 +58,11 @@ test("each agentSkillTool has name, description, inputSchema, and handler", () = // ─── omniroute_agent_skills_list ──────────────────────────────────────────── -test("omniroute_agent_skills_list with no filters returns all 44 skills", async () => { +test("omniroute_agent_skills_list with no filters returns all 45 skills", async () => { const result = await agentSkillTools.omniroute_agent_skills_list.handler({}); - assert.equal(result.count, 44, `Expected 44 but got ${result.count}`); + assert.equal(result.count, 45, `Expected 45 but got ${result.count}`); assert.ok(Array.isArray(result.skills)); - assert.equal(result.skills.length, 44); + assert.equal(result.skills.length, 45); }); test("omniroute_agent_skills_list({category:'api'}) returns exactly 23 entries", async () => { @@ -71,9 +71,9 @@ test("omniroute_agent_skills_list({category:'api'}) returns exactly 23 entries", assert.ok(result.skills.every((s: { category: string }) => s.category === "api")); }); -test("omniroute_agent_skills_list({category:'cli'}) returns exactly 20 entries", async () => { +test("omniroute_agent_skills_list({category:'cli'}) returns exactly 21 entries", async () => { const result = await agentSkillTools.omniroute_agent_skills_list.handler({ category: "cli" }); - assert.equal(result.count, 20, `Expected 20 cli skills but got ${result.count}`); + assert.equal(result.count, 21, `Expected 21 cli skills but got ${result.count}`); assert.ok(result.skills.every((s: { category: string }) => s.category === "cli")); }); @@ -83,7 +83,7 @@ test("omniroute_agent_skills_list result includes coverage shape", async () => { assert.ok(typeof result.coverage.api === "object"); assert.ok(typeof result.coverage.cli === "object"); assert.equal(result.coverage.api.total, 23); - assert.equal(result.coverage.cli.total, 20); + assert.equal(result.coverage.cli.total, 21); assert.ok(typeof result.coverage.totalSkills === "number"); assert.ok(typeof result.coverage.generatedAt === "string"); }); @@ -168,11 +168,11 @@ test("omniroute_agent_skills_coverage({}) returns coverage shape", async () => { assert.ok(typeof result.api === "object"); assert.ok(typeof result.cli === "object"); assert.equal(result.api.total, 23); - assert.equal(result.cli.total, 20); + assert.equal(result.cli.total, 21); assert.ok(typeof result.api.have === "number"); assert.ok(typeof result.cli.have === "number"); assert.ok(result.api.have >= 0 && result.api.have <= 23); - assert.ok(result.cli.have >= 0 && result.cli.have <= 20); + assert.ok(result.cli.have >= 0 && result.cli.have <= 21); assert.ok(typeof result.totalSkills === "number"); assert.equal(result.totalSkills, result.api.have + result.cli.have + (result.config?.have ?? 0)); assert.ok(typeof result.generatedAt === "string"); diff --git a/tests/unit/agentSkills-catalog.test.ts b/tests/unit/agentSkills-catalog.test.ts index 9820c627c7..eec31d49ad 100644 --- a/tests/unit/agentSkills-catalog.test.ts +++ b/tests/unit/agentSkills-catalog.test.ts @@ -15,10 +15,10 @@ const agentSkillsConstants = await import("../../src/shared/constants/agentSkill // ─── Counts ─────────────────────────────────────────────────────────────────── -test("getCatalog() returns exactly 44 entries", () => { +test("getCatalog() returns exactly 45 entries", () => { refreshCatalog(); const catalog = getCatalog(); - assert.equal(catalog.length, 44, `Expected 44 but got ${catalog.length}`); + assert.equal(catalog.length, 45, `Expected 45 but got ${catalog.length}`); }); test("API_SKILL_IDS has exactly 23 entries", () => { @@ -26,7 +26,7 @@ test("API_SKILL_IDS has exactly 23 entries", () => { }); test("CLI_SKILL_IDS has exactly 20 entries", () => { - assert.equal(CLI_SKILL_IDS.length, 20); + assert.equal(CLI_SKILL_IDS.length, 21); }); test("getCatalog() contains exactly 22 api skills", () => { @@ -34,9 +34,9 @@ test("getCatalog() contains exactly 22 api skills", () => { assert.equal(apiSkills.length, 23); }); -test("getCatalog() contains exactly 20 cli skills", () => { +test("getCatalog() contains exactly 21 cli skills", () => { const cliSkills = getCatalog().filter((s) => s.category === "cli"); - assert.equal(cliSkills.length, 20); + assert.equal(cliSkills.length, 21); }); // ─── ID format ──────────────────────────────────────────────────────────────── @@ -160,9 +160,9 @@ test("filterCatalog({ category: 'api' }) returns 23 api skills", () => { } }); -test("filterCatalog({ category: 'cli' }) returns 20 cli skills", () => { +test("filterCatalog({ category: 'cli' }) returns 21 cli skills", () => { const skills = filterCatalog({ category: "cli" }); - assert.equal(skills.length, 20); + assert.equal(skills.length, 21); for (const s of skills) { assert.equal(s.category, "cli"); } @@ -185,9 +185,9 @@ test("filterCatalog({ area: 'nonexistent' }) returns empty array", () => { assert.equal(skills.length, 0); }); -test("filterCatalog({}) returns full catalog (44 entries)", () => { +test("filterCatalog({}) returns full catalog (45 entries)", () => { const skills = filterCatalog({}); - assert.equal(skills.length, 44); + assert.equal(skills.length, 45); }); // ─── refreshCatalog ─────────────────────────────────────────────────────────── @@ -214,9 +214,9 @@ test("computeCoverage() returns valid SkillCoverage shape", () => { assert.ok(cov.api.have >= 0 && cov.api.have <= 23); assert.ok(typeof cov.cli === "object"); - assert.equal(cov.cli.total, 20); + assert.equal(cov.cli.total, 21); assert.ok(typeof cov.cli.have === "number"); - assert.ok(cov.cli.have >= 0 && cov.cli.have <= 20); + assert.ok(cov.cli.have >= 0 && cov.cli.have <= 21); assert.equal(cov.totalSkills, cov.api.have + cov.cli.have + (cov.config?.have ?? 0)); @@ -255,6 +255,6 @@ test("CLI_SKILL_IDS first entry is cli-serve", () => { assert.equal(CLI_SKILL_IDS[0], "cli-serve"); }); -test("CLI_SKILL_IDS last entry is cli-setup", () => { - assert.equal(CLI_SKILL_IDS[CLI_SKILL_IDS.length - 1], "cli-setup"); +test("CLI_SKILL_IDS last entry is cli-skill-collector", () => { + assert.equal(CLI_SKILL_IDS[CLI_SKILL_IDS.length - 1], "cli-skill-collector"); }); diff --git a/tests/unit/agentSkills-generator.test.ts b/tests/unit/agentSkills-generator.test.ts index db54f73ad2..95475887d9 100644 --- a/tests/unit/agentSkills-generator.test.ts +++ b/tests/unit/agentSkills-generator.test.ts @@ -61,11 +61,11 @@ test("dry-run (default) returns report without writing any files", async () => { outputDir: tmpDir, }); - // All 44 skills should appear as generated (would-write) since dir is empty + // All 45 skills should appear as generated (would-write) since dir is empty assert.equal( report.generated.length + report.unchanged.length, - 44, - `Expected 44 total (generated+unchanged), got generated=${report.generated.length} unchanged=${report.unchanged.length}`, + 45, + `Expected 45 total (generated+unchanged), got generated=${report.generated.length} unchanged=${report.unchanged.length}`, ); assert.equal(report.errors.length, 0, `Unexpected errors: ${JSON.stringify(report.errors)}`); @@ -81,7 +81,7 @@ test("dry-run (default) returns report without writing any files", async () => { } }); -test("dry-run generates report with 44 total (generated+unchanged)", async () => { +test("dry-run generates report with 45 total (generated+unchanged)", async () => { const tmpDir = mkTmpDir(); try { refreshCatalog(); @@ -91,7 +91,7 @@ test("dry-run generates report with 44 total (generated+unchanged)", async () => outputDir: tmpDir, }); const total = report.generated.length + report.unchanged.length; - assert.equal(total, 44); + assert.equal(total, 45); } finally { rmTmpDir(tmpDir); } @@ -134,7 +134,7 @@ test("apply mode writes SKILL.md with valid frontmatter for omni-providers", asy } }); -test("apply mode writes all 44 SKILL.md files when no onlyIds filter", async () => { +test("apply mode writes all 45 SKILL.md files when no onlyIds filter", async () => { const tmpDir = mkTmpDir(); try { refreshCatalog(); @@ -145,7 +145,7 @@ test("apply mode writes all 44 SKILL.md files when no onlyIds filter", async () }); assert.equal(report.errors.length, 0, `Errors: ${JSON.stringify(report.errors)}`); - assert.equal(report.generated.length, 44); + assert.equal(report.generated.length, 45); // Verify all dirs exist const catalog = getCatalog(); diff --git a/tests/unit/agentSkills-routes.test.ts b/tests/unit/agentSkills-routes.test.ts index 16265f4a4f..1de6eccf2d 100644 --- a/tests/unit/agentSkills-routes.test.ts +++ b/tests/unit/agentSkills-routes.test.ts @@ -101,15 +101,15 @@ test.after(() => { // GET /api/agent-skills // ═════════════════════════════════════════════════════════════════════════════ -test("GET /api/agent-skills — returns 44 skills with count and coverage", async () => { +test("GET /api/agent-skills — returns 45 skills with count and coverage", async () => { const req = makeRequest("GET", "http://localhost/api/agent-skills"); const res = await listRoute.GET(req); assert.equal(res.status, 200); const body = (await res.json()) as { skills: unknown[]; count: number; coverage: unknown }; - assert.equal(body.count, 44, `Expected 44 skills but got ${body.count}`); + assert.equal(body.count, 45, `Expected 45 skills but got ${body.count}`); assert.equal(Array.isArray(body.skills), true); - assert.equal(body.skills.length, 44); + assert.equal(body.skills.length, 45); assert.ok(body.coverage !== undefined, "coverage should be present"); }); @@ -123,13 +123,13 @@ test("GET /api/agent-skills?category=api — returns 23 api skills", async () => assert.ok(body.skills.every((s) => s.category === "api"), "All skills should be api category"); }); -test("GET /api/agent-skills?category=cli — returns 20 cli skills", async () => { +test("GET /api/agent-skills?category=cli — returns 21 cli skills", async () => { const req = makeRequest("GET", "http://localhost/api/agent-skills?category=cli"); const res = await listRoute.GET(req); assert.equal(res.status, 200); const body = (await res.json()) as { skills: Array<{ category: string }>; count: number }; - assert.equal(body.count, 20); + assert.equal(body.count, 21); assert.ok(body.skills.every((s) => s.category === "cli"), "All skills should be cli category"); }); @@ -268,7 +268,7 @@ test("GET /api/agent-skills/coverage — returns valid SkillCoverage shape", asy }; assert.equal(body.api.total, 23, "api.total must be 23"); - assert.equal(body.cli.total, 20, "cli.total must be 20"); + assert.equal(body.cli.total, 21, "cli.total must be 21"); assert.ok(typeof body.totalSkills === "number", "totalSkills must be a number"); assert.ok(typeof body.generatedAt === "string", "generatedAt must be a string"); // generatedAt must be a valid ISO datetime diff --git a/tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts b/tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts new file mode 100644 index 0000000000..41d242c1f5 --- /dev/null +++ b/tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts @@ -0,0 +1,63 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; +import { + clearAntigravityVersionCache, + seedAntigravityVersionCache, +} from "../../open-sse/services/antigravityVersion.ts"; + +// Ports decolua/9router#2461: a non-ok (e.g. 403) Antigravity upstream response in the +// STREAMING path was piped straight through to the client via a raw pass-through +// TransformStream, with no `response.ok` check at all — unlike the non-streaming path, +// which already builds a sanitized error via buildAntigravityUpstreamError. When the +// upstream 403 body is gzip-compressed (or otherwise binary/non-UTF8), those raw bytes +// end up surfaced verbatim in the client-visible error message, corrupting it (reporters +// saw literal control-byte garbage after "[ERROR] [403]:"). +test.afterEach(() => { + clearAntigravityVersionCache(); +}); + +test("AntigravityExecutor.execute (stream=true) sanitizes a non-ok upstream body instead of piping raw bytes", async () => { + const executor = new AntigravityExecutor(); + const originalFetch = globalThis.fetch; + seedAntigravityVersionCache("2026.04.17-test"); + + // Simulate a gzip-compressed 403 body (magic bytes 0x1f 0x8b), the exact shape + // reported upstream — reading it as text without decoding produces garbage. + const binaryBody = new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0x02, 0xff, 0x52, 0x41, 0x4e]); + + globalThis.fetch = async () => + new Response(binaryBody, { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + + try { + const result = await executor.execute({ + model: "antigravity/gemini-2.5-flash", + body: { request: { contents: [] } }, + stream: true, + credentials: { accessToken: "token", projectId: "project-1" }, + log: { debug() {}, warn() {} }, + }); + + assert.equal(result.response.status, 403); + + const bodyText = await result.response.text(); + + // The raw gzip magic bytes must never reach the client-visible error text. + assert.ok( + !bodyText.includes("\x1f\x8b"), + `expected sanitized error body, got raw bytes leaking through: ${JSON.stringify(bodyText)}` + ); + + // Must be routed through buildErrorBody()/buildAntigravityUpstreamError() — a clean, + // parseable JSON error shape (hard rule #12), not an arbitrary pass-through stream. + const parsed = JSON.parse(bodyText) as { error?: { message?: string } }; + assert.ok(parsed.error?.message, "expected a structured error.message"); + assert.match(parsed.error.message, /Antigravity upstream error \(403\)/); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/api/services/cliproxy-provider-expose.test.ts b/tests/unit/api/services/cliproxy-provider-expose.test.ts new file mode 100644 index 0000000000..3376c532df --- /dev/null +++ b/tests/unit/api/services/cliproxy-provider-expose.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for POST /api/services/cliproxy/provider-expose + */ + +import { beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cliproxy-provider-expose-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.NODE_ENV = "test"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +// Initialise DB first. +const core = await import("../../../../src/lib/db/core.ts"); +const { upsertVersionManagerTool, getVersionManagerTool } = + await import("../../../../src/lib/db/versionManager.ts"); + +const { POST } = await import("../../../../src/app/api/services/cliproxy/provider-expose/route.ts"); + +function resetDb() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function makeRequest(body: unknown): Request { + return new Request("http://localhost/api/services/cliproxy/provider-expose", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + resetDb(); +}); + +describe("POST /api/services/cliproxy/provider-expose", () => { + it("returns 400 for invalid JSON body", async () => { + const req = new Request("http://localhost/api/services/cliproxy/provider-expose", { + method: "POST", + body: "not-json", + headers: { "Content-Type": "application/json" }, + }); + const res = await POST(req); + assert.equal(res.status, 400); + }); + + it("returns 400 when enabled is missing", async () => { + const req = makeRequest({}); + const res = await POST(req); + assert.equal(res.status, 400); + const body = await res.json(); + assert.ok(body?.error?.message ?? body?.message, "should have an error message"); + }); + + it("returns 400 when enabled is not a boolean", async () => { + const req = makeRequest({ enabled: "yes" }); + const res = await POST(req); + assert.equal(res.status, 400); + }); + + it("sets providerExpose to true and returns 204", async () => { + await upsertVersionManagerTool({ tool: "cliproxy", status: "stopped" }); + + const req = makeRequest({ enabled: true }); + const res = await POST(req); + assert.equal(res.status, 204); + + const row = await getVersionManagerTool("cliproxy"); + assert.equal(row?.providerExpose, true, "providerExpose should be true in DB"); + }); + + it("sets providerExpose to false and returns 204", async () => { + await upsertVersionManagerTool({ tool: "cliproxy", status: "stopped" }); + + await POST(makeRequest({ enabled: true })); + const res = await POST(makeRequest({ enabled: false })); + assert.equal(res.status, 204); + + const row = await getVersionManagerTool("cliproxy"); + assert.equal(row?.providerExpose, false, "providerExpose should be false in DB"); + }); + + it("error response does not leak stack traces", async () => { + const req = makeRequest({}); + const res = await POST(req); + assert.equal(res.status, 400); + const text = await res.text(); + assert.ok(!text.includes("at /"), "error body must not expose stack trace"); + }); +}); diff --git a/tests/unit/api/v1/bifrost-sidecar.test.ts b/tests/unit/api/v1/bifrost-sidecar.test.ts index 286e8bffcb..8e9f5a3c8f 100644 --- a/tests/unit/api/v1/bifrost-sidecar.test.ts +++ b/tests/unit/api/v1/bifrost-sidecar.test.ts @@ -79,9 +79,8 @@ test("bifrost route: returns 503 + fallback header when BIFROST_BASE_URL is unse delete process.env.BIFROST_STREAMING_ENABLED; // Dynamic import after env is set so the module reads the empty value. - const { POST } = await import( - "../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts" - ); + const { POST } = + await import("../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts"); const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", { method: "POST", @@ -191,12 +190,14 @@ test("bifrost route: records relay usage after SSE stream completion", async () delete process.env.BIFROST_STREAMING_ENABLED; const relayToken = seedRelayToken(`relay_bifrost_sse_${Date.now()}`); + let forwardedRequestId: string | null = null; - globalThis.fetch = async () => - new Response( + globalThis.fetch = async (_input, init) => { + forwardedRequestId = new Headers(init?.headers).get("x-request-id"); + return new Response( new ReadableStream({ start(controller) { - controller.enqueue(new TextEncoder().encode("data: {\"delta\":\"hi\"}\n\n")); + controller.enqueue(new TextEncoder().encode('data: {"delta":"hi"}\n\n')); controller.close(); }, }), @@ -205,6 +206,7 @@ test("bifrost route: records relay usage after SSE stream completion", async () headers: { "content-type": "text/event-stream" }, } ); + }; const { POST } = await import( `../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}` @@ -227,6 +229,7 @@ test("bifrost route: records relay usage after SSE stream completion", async () const res = await POST(req); assert.equal(res.status, 200); assert.equal(res.headers.get("X-Routed-By"), "bifrost"); + assert.equal(forwardedRequestId, "bifrost-sse-lifecycle-test"); assert.equal(getRelayLogs(relayToken.id, 10).length, 0); assert.match(await res.text(), /delta/); diff --git a/tests/unit/api/v1/provider-plugin-manifest-route.test.ts b/tests/unit/api/v1/provider-plugin-manifest-route.test.ts index 9af7baa46d..fa59013021 100644 --- a/tests/unit/api/v1/provider-plugin-manifest-route.test.ts +++ b/tests/unit/api/v1/provider-plugin-manifest-route.test.ts @@ -4,18 +4,70 @@ import test from "node:test"; import { GET, OPTIONS, + injectServiceModelsIntoManifest, } from "../../../../src/app/api/v1/provider-plugin-manifest/route.ts"; +import type { ServiceModel } from "../../../../src/lib/db/serviceModels.ts"; +import type { ProviderPluginManifest, ProviderPluginManifestEntry } from "../../../../open-sse/config/providerPluginManifest.ts"; +import { generateProviderPluginManifest } from "../../../../open-sse/config/providerPluginManifestRegistry.ts"; + +function getProvider(manifest: ProviderPluginManifest, id: string): ProviderPluginManifestEntry | undefined { + return manifest.providers.find((provider) => provider.id === id); +} + +function hasModel(provider: ProviderPluginManifestEntry | undefined, modelId: string): boolean { + if (!provider) return false; + return provider.models.some((model) => model.id === modelId); +} + +function withServicePluginEntries(manifest: ProviderPluginManifest): ProviderPluginManifest { + const providers = [...manifest.providers]; + + if (!providers.some((provider) => provider.id === "9router")) { + providers.push({ + id: "9router", + format: "openai", + executor: "default", + auth: { type: "none", header: "authorization" }, + endpoints: {}, + capabilities: [], + passthroughModels: false, + models: [], + sidecar: { eligible: false, reasons: [] }, + }); + } + + if (!providers.some((provider) => provider.id === "cliproxyapi")) { + providers.push({ + id: "cliproxyapi", + format: "openai", + executor: "default", + auth: { type: "none", header: "authorization" }, + endpoints: {}, + capabilities: [], + passthroughModels: false, + models: [], + sidecar: { eligible: false, reasons: [] }, + }); + } + + providers.sort((a, b) => a.id.localeCompare(b.id)); + + return { + ...manifest, + providers, + }; +} test("provider plugin manifest route returns JSON-safe manifest", async () => { const response = await GET(); - const body = await response.json(); + const body = (await response.json()) as ProviderPluginManifest; assert.equal(response.status, 200); assert.equal(response.headers.get("Content-Type"), "application/json"); assert.equal(body.schemaVersion, 1); assert.equal(body.generatedFrom, "open-sse/config/providers"); assert.ok(body.providers.length > 100); - assert.ok(body.providers.some((provider: { id: string }) => provider.id === "openai")); + assert.ok(body.providers.some((provider) => provider.id === "openai")); const serialized = JSON.stringify(body); assert.equal(serialized.includes("clientSecret"), false); @@ -28,3 +80,136 @@ test("provider plugin manifest route handles CORS preflight", async () => { assert.equal(response.headers.get("Access-Control-Allow-Methods"), "GET, OPTIONS"); assert.equal(response.headers.get("Access-Control-Allow-Headers"), "*"); }); + +test("provider plugin manifest route injects service models with a custom reader", async () => { + const manifest = withServicePluginEntries(generateProviderPluginManifest()); + const withModels = await injectServiceModelsIntoManifest( + manifest, + (toolName: string): ServiceModel[] => { + if (toolName === "9router") { + return [ + { id: "gpt-test", name: "9Router Test", available: true }, + { id: "9router/chat", name: "Already namespaced", available: true }, + ]; + } + if (toolName === "cliproxyapi") { + return [{ id: "model-clone", name: "Cliproxy Test", available: true }]; + } + return []; + }, + ); + + const nineRouterEntry = getProvider(withModels, "9router"); + assert.ok(nineRouterEntry); + assert.ok(hasModel(nineRouterEntry, "9router/gpt-test")); + assert.ok(hasModel(nineRouterEntry, "9router/chat")); + + const cliproxyEntry = getProvider(withModels, "cliproxyapi"); + assert.ok(cliproxyEntry); + assert.ok(hasModel(cliproxyEntry, "cliproxyapi/model-clone")); +}); + +test("provider plugin manifest route injects providers absent from upstream registry", async () => { + const manifest = generateProviderPluginManifest(); + const withModels = await injectServiceModelsIntoManifest( + manifest, + (toolName: string): ServiceModel[] => { + if (toolName === "9router") { + return [{ id: "injected-model", name: "Runtime Model", available: true }]; + } + if (toolName === "cliproxyapi") { + return [{ id: "proxy-model", name: "Proxy Model", available: true }]; + } + return []; + } + ); + + const nineRouterEntry = getProvider(withModels, "9router"); + assert.ok(nineRouterEntry); + assert.ok(hasModel(nineRouterEntry, "9router/injected-model")); + assert.equal(nineRouterEntry.passthroughModels, true); + assert.equal(nineRouterEntry.endpoints?.modelsUrl, "/v1/models"); + assert.equal(nineRouterEntry.format, "openai"); + + const cliproxyEntry = getProvider(withModels, "cliproxyapi"); + assert.ok(cliproxyEntry); + assert.ok(hasModel(cliproxyEntry, "cliproxyapi/proxy-model")); + assert.equal(cliproxyEntry.passthroughModels, true); + assert.equal(cliproxyEntry.endpoints?.modelsUrl, "/v1/models"); + assert.equal(cliproxyEntry.format, "openai"); +}); + +test("provider plugin manifest route skips unavailable service models", async () => { + const manifest = withServicePluginEntries(generateProviderPluginManifest()); + const withModels = await injectServiceModelsIntoManifest( + manifest, + (toolName: string): ServiceModel[] => { + if (toolName === "9router") { + return [ + { id: "visible", name: "9Router Visible", available: true }, + { id: "hidden", name: "9Router Hidden", available: false }, + ]; + } + return []; + }, + ); + + const nineRouterEntry = getProvider(withModels, "9router"); + assert.ok(nineRouterEntry); + assert.ok(hasModel(nineRouterEntry, "9router/visible")); + assert.equal(hasModel(nineRouterEntry, "9router/hidden"), false); +}); + +test("provider plugin manifest route injects only when 9router exposure is enabled", async () => { + const manifest = withServicePluginEntries(generateProviderPluginManifest()); + const withModels = await injectServiceModelsIntoManifest( + manifest, + (toolName: string): ServiceModel[] => { + if (toolName === "9router") { + return [{ id: "gpt-test", name: "9Router Test" }]; + } + return []; + }, + (toolName: string): boolean => (toolName === "9router" ? false : true), + ); + + const nineRouterEntry = getProvider(withModels, "9router"); + assert.ok(nineRouterEntry); + assert.equal(hasModel(nineRouterEntry, "9router/gpt-test"), false); +}); + +test("provider plugin manifest route injects for cliproxy when exposure is enabled", async () => { + const manifest = withServicePluginEntries(generateProviderPluginManifest()); + const withModels = await injectServiceModelsIntoManifest( + manifest, + (toolName: string): ServiceModel[] => { + if (toolName === "cliproxyapi") { + return [{ id: "model-clone", name: "Cliproxy Test" }]; + } + return []; + }, + () => true, + ); + + const cliproxyEntry = getProvider(withModels, "cliproxyapi"); + assert.ok(cliproxyEntry); + assert.ok(hasModel(cliproxyEntry, "cliproxyapi/model-clone")); +}); + +test("provider plugin manifest route skips cliproxy models when exposure is disabled", async () => { + const manifest = withServicePluginEntries(generateProviderPluginManifest()); + const withModels = await injectServiceModelsIntoManifest( + manifest, + (toolName: string): ServiceModel[] => { + if (toolName === "cliproxyapi") { + return [{ id: "model-clone", name: "Cliproxy Test" }]; + } + return []; + }, + (toolName: string): boolean => (toolName === "cliproxyapi" ? false : true), + ); + + const cliproxyEntry = getProvider(withModels, "cliproxyapi"); + assert.ok(cliproxyEntry); + assert.equal(hasModel(cliproxyEntry, "cliproxyapi/model-clone"), false); +}); diff --git a/tests/unit/api/v1/relay-routing-backend.test.ts b/tests/unit/api/v1/relay-routing-backend.test.ts index 6e8e0e0aad..64cca27097 100644 --- a/tests/unit/api/v1/relay-routing-backend.test.ts +++ b/tests/unit/api/v1/relay-routing-backend.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { getBifrostRoutingConfig, getRoutingFallbackHeader, @@ -152,3 +153,26 @@ test("relay routing backend strict bifrost bypasses manifest eligibility", () => { tryBifrost: true } ); }); + +test("automatic relay keeps the Bifrost timeout active until an SSE stream finalizes", () => { + const routeSource = readFileSync( + new URL("../../../../src/app/api/v1/relay/chat/completions/route.ts", import.meta.url), + "utf8" + ); + const forwardToBifrost = routeSource.slice( + routeSource.indexOf("async function forwardToBifrost"), + routeSource.indexOf("export async function OPTIONS") + ); + const streamBranch = forwardToBifrost.slice( + forwardToBifrost.indexOf("if (wantsStream && upstream.body)"), + forwardToBifrost.indexOf("clearTimeout(tid);\n recordUsage(") + ); + + assert.match( + streamBranch, + /finalizeReadableStream\(upstream\.body, \(error\) => \{\s*clearTimeout\(tid\)/ + ); + assert.match(streamBranch, /const statusCode = timedOut \? 504 : upstream\.status/); + assert.match(streamBranch, /error && backend === "auto"/); + assert.match(streamBranch, /recordBifrostFailure\(/); +}); diff --git a/tests/unit/balance-e2e-shards.test.ts b/tests/unit/balance-e2e-shards.test.ts new file mode 100644 index 0000000000..6484dfccb2 --- /dev/null +++ b/tests/unit/balance-e2e-shards.test.ts @@ -0,0 +1,75 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { lptAssign, weightItems } from "../../scripts/quality/balance-e2e-shards.mjs"; + +// WS4.1 (v3.8.49 quality plan) — the E2E matrix skew was 14× (24m47s vs 1m47s) +// because Playwright --shard distributes by count, not duration. These tests pin +// the LPT packing invariants; the hard one is COMPLETENESS (a lost spec would +// silently hollow the suite — the CLI self-checks it and falls back to --shard). + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +test("lptAssign puts the heaviest item alone before doubling up lighter shards", () => { + const shards = lptAssign( + [ + { file: "huge.spec.ts", weight: 100 }, + { file: "a.spec.ts", weight: 30 }, + { file: "b.spec.ts", weight: 30 }, + { file: "c.spec.ts", weight: 30 }, + ], + 2 + ); + assert.deepEqual(shards[0].files, ["huge.spec.ts"]); + assert.deepEqual(shards[1].files, ["a.spec.ts", "b.spec.ts", "c.spec.ts"]); + assert.equal(shards[0].total, 100); + assert.equal(shards[1].total, 90); +}); + +test("lptAssign is deterministic on equal weights (filename tiebreak)", () => { + const items = [ + { file: "b.spec.ts", weight: 10 }, + { file: "a.spec.ts", weight: 10 }, + ]; + const s1 = lptAssign(items, 2); + const s2 = lptAssign([...items].reverse(), 2); + assert.deepEqual(s1, s2); +}); + +test("completeness: every file lands in exactly one shard", () => { + const items = Array.from({ length: 37 }, (_, i) => ({ + file: `f${String(i).padStart(2, "0")}.spec.ts`, + weight: (i * 7) % 40, + })); + const shards = lptAssign(items, 9); + const union = shards.flatMap((s) => s.files).sort(); + assert.deepEqual(union, items.map((i) => i.file).sort()); +}); + +test("weightItems gives unknown/new specs the median weight, not an extreme", () => { + const items = weightItems(["new.spec.ts", "big.spec.ts", "small.spec.ts"], { + _meta: "x", + "big.spec.ts": 600, + "small.spec.ts": 20, + "other.spec.ts": 100, + }); + const byFile = Object.fromEntries(items.map((i) => [i.file, i.weight])); + assert.equal(byFile["big.spec.ts"], 600); + assert.equal(byFile["small.spec.ts"], 20); + assert.equal(byFile["new.spec.ts"], 100); // median of [20,100,600] +}); + +test("the committed timings seed covers every current e2e spec (no drift)", () => { + const timings = JSON.parse( + fs.readFileSync(path.join(ROOT, "config", "quality", "e2e-timings.json"), "utf8") + ); + const specs = fs + .readdirSync(path.join(ROOT, "tests", "e2e")) + .filter((f) => f.endsWith(".spec.ts")); + const missing = specs.filter((f) => !(f in timings)); + // Missing entries are tolerated at runtime (median fallback) — this assert keeps + // the seed honest so balance quality does not silently rot as specs are added. + assert.deepEqual(missing, [], `add to config/quality/e2e-timings.json: ${missing.join(", ")}`); +}); diff --git a/tests/unit/build/check-dashboard-typecheck.test.ts b/tests/unit/build/check-dashboard-typecheck.test.ts new file mode 100644 index 0000000000..dec6d54fad --- /dev/null +++ b/tests/unit/build/check-dashboard-typecheck.test.ts @@ -0,0 +1,111 @@ +// tests/unit/build/check-dashboard-typecheck.test.ts +// Unit tests for the pure parsing/diff helpers in check-dashboard-typecheck.mjs. +// No child process is spawned — synthetic tsc-style output only, so the suite is +// fast and hermetic. Proves the gate actually DETECTS the #6625/#6909 bug class +// (an orphaned identifier — used but not declared — in a dashboard TSX file), +// not just that the script runs. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + parseTscOutput, + diffAgainstBaseline, +} from "../../../scripts/check/check-dashboard-typecheck.mjs"; + +test("parseTscOutput: parses a TS2304 orphaned-identifier error (the #6625/#6909 bug class)", () => { + const raw = + `src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx(564,7): error TS2304: Cannot find name 'setPoolLoaded'.\n` + + `src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx(1204,12): error TS2304: Cannot find name 'poolLoaded'.\n`; + + const counts = parseTscOutput(raw); + + assert.deepEqual(counts, { + "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": { + TS2304: 2, + }, + }); +}); + +test("parseTscOutput: ignores non-error lines (summary/info output)", () => { + const raw = + `Some info line that is not an error\n` + + `src/app/(dashboard)/dashboard/foo.tsx(1,1): error TS2339: Property 'bar' does not exist.\n` + + `Found 1 error in 1 file.\n`; + + const counts = parseTscOutput(raw); + + assert.deepEqual(counts, { + "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 1 }, + }); +}); + +test("parseTscOutput: returns empty map for clean output", () => { + assert.deepEqual(parseTscOutput(""), {}); + assert.deepEqual(parseTscOutput("Found 0 errors.\n"), {}); +}); + +test("diffAgainstBaseline: flags a brand-new orphaned-identifier error as a regression", () => { + const baseline = {}; + const live = { + "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": { + TS2304: 5, + }, + }; + + const { regressions, improvements } = diffAgainstBaseline(live, baseline); + + assert.equal(regressions.length, 1); + assert.equal( + regressions[0].file, + "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx" + ); + assert.equal(regressions[0].code, "TS2304"); + assert.equal(regressions[0].liveCount, 5); + assert.equal(regressions[0].baselineCount, 0); + assert.equal(improvements.length, 0); +}); + +test("diffAgainstBaseline: does NOT flag a frozen pre-existing error within its baselined count", () => { + const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } }; + const live = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } }; + + const { regressions, improvements } = diffAgainstBaseline(live, baseline); + + assert.equal(regressions.length, 0); + assert.equal(improvements.length, 0); +}); + +test("diffAgainstBaseline: flags a count INCREASE beyond the frozen baseline as a regression", () => { + const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 2 } }; + const live = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } }; + + const { regressions } = diffAgainstBaseline(live, baseline); + + assert.equal(regressions.length, 1); + assert.equal(regressions[0].baselineCount, 2); + assert.equal(regressions[0].liveCount, 3); +}); + +test("diffAgainstBaseline: reports (does not fail on) a count DECREASE as an improvement", () => { + const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } }; + const live = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 1 } }; + + const { regressions, improvements } = diffAgainstBaseline(live, baseline); + + assert.equal(regressions.length, 0); + assert.equal(improvements.length, 1); + assert.equal(improvements[0].baselineCount, 3); + assert.equal(improvements[0].liveCount, 1); +}); + +test("diffAgainstBaseline: a baselined error that fully disappears is reported as an improvement, not a failure", () => { + const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 2 } }; + const live = {}; + + const { regressions, improvements } = diffAgainstBaseline(live, baseline); + + assert.equal(regressions.length, 0); + assert.equal(improvements.length, 1); + assert.equal(improvements[0].liveCount, 0); + assert.equal(improvements[0].baselineCount, 2); +}); diff --git a/tests/unit/chat-combo-live-test.test.ts b/tests/unit/chat-combo-live-test.test.ts index a734b02bc4..79f8a35a32 100644 --- a/tests/unit/chat-combo-live-test.test.ts +++ b/tests/unit/chat-combo-live-test.test.ts @@ -274,7 +274,10 @@ test("chat completions route emits early keepalive while waiting for stream read assert.match(response.headers.get("content-type") || "", /text\/event-stream/); const body = await readAll(response); - assert.match(body, /: omniroute-keepalive/); + assert.match( + body, + /data: \{"id":"omniroute-keepalive","object":"chat\.completion\.chunk"/ + ); assert.match(body, /OK/); assert.match(body, /\[DONE\]/); }); diff --git a/tests/unit/check-pack-boot.test.ts b/tests/unit/check-pack-boot.test.ts new file mode 100644 index 0000000000..b22ca557b5 --- /dev/null +++ b/tests/unit/check-pack-boot.test.ts @@ -0,0 +1,57 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { pickTarball, evaluateBoot, pickPort } from "../../scripts/check/check-pack-boot.mjs"; + +// WS1.2 (T1, v3.8.49 quality plan) — pure-function guards for the tarball boot-smoke +// gate that kills the #7065 class (published artifact crashes on every boot because a +// packaging list drifted; 3rd recurrence). The end-to-end path runs in CI's +// package-artifact job; these tests pin the decision logic. + +const SCRIPT_PATH = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../../scripts/check/check-pack-boot.mjs" +); + +test("pickTarball extracts the filename from npm pack --json output", () => { + assert.equal(pickTarball('[{"filename":"omniroute-3.8.49.tgz","size":1}]'), "omniroute-3.8.49.tgz"); +}); + +test("pickTarball normalizes scoped slashes to the on-disk dash form", () => { + assert.equal(pickTarball('[{"filename":"@scope/pkg-1.0.0.tgz"}]'), "@scope-pkg-1.0.0.tgz"); +}); + +test("pickTarball throws on empty/odd npm output instead of booting garbage", () => { + assert.throws(() => pickTarball("[]")); + assert.throws(() => pickTarball("{}")); +}); + +test("evaluateBoot passes on HTTP 200 + matching version, whatever the health status", () => { + const r = evaluateBoot(200, { version: "3.8.49", status: "warning" }, "3.8.49"); + assert.equal(r.ok, true); + assert.deepEqual(r.failures, []); +}); + +test("evaluateBoot fails on non-200, non-JSON body, and version mismatch", () => { + assert.equal(evaluateBoot(503, { version: "3.8.49" }, "3.8.49").ok, false); + assert.equal(evaluateBoot(200, null, "3.8.49").ok, false); + const wrong = evaluateBoot(200, { version: "3.8.48" }, "3.8.49"); + assert.equal(wrong.ok, false); + assert.match(wrong.failures[0], /3\.8\.48/); +}); + +test("pickPort stays inside the reserved smoke range for any pid", () => { + for (const seed of [0, 1, 4000, 65535, 123456]) { + const p = pickPort(seed); + assert.ok(p >= 23000 && p < 27000, `port ${p} out of range for seed ${seed}`); + } +}); + +test("source guard: the gate polls the real health endpoint of the INSTALLED binary", () => { + const src = readFileSync(SCRIPT_PATH, "utf8"); + assert.ok(src.includes('"install", "-g", "--prefix"'), "must install the packed tarball into a clean prefix"); + assert.ok(src.includes("/api/monitoring/health"), "must poll the health endpoint"); + assert.ok(src.indexOf("npm") < src.indexOf("spawn"), "pack+install must precede the boot spawn"); +}); diff --git a/tests/unit/classify-pr-changes.test.ts b/tests/unit/classify-pr-changes.test.ts index a2815786cc..4f4e30154d 100644 --- a/tests/unit/classify-pr-changes.test.ts +++ b/tests/unit/classify-pr-changes.test.ts @@ -15,7 +15,7 @@ import { classifyPaths } from "../../scripts/quality/classify-pr-changes.mjs"; test("pure docs PR → docs only (no code unit/lint bag)", () => { const c = classifyPaths(["docs/architecture/QUALITY_GATES.md", "README.md"]); - assert.deepEqual(c, { code: false, docs: true, i18n: false, workflow: false }); + assert.deepEqual(c, { code: false, docs: true, i18n: false, workflow: false, testsOnly: false }); }); test("openapi under docs/ → docs (contract gates live in docs-sync, not unit)", () => { @@ -26,7 +26,7 @@ test("openapi under docs/ → docs (contract gates live in docs-sync, not unit)" test("pure message catalog → i18n only (not full unit suite)", () => { const c = classifyPaths(["src/i18n/messages/en.json", "src/i18n/messages/ko.json"]); - assert.deepEqual(c, { code: false, docs: false, i18n: true, workflow: false }); + assert.deepEqual(c, { code: false, docs: false, i18n: true, workflow: false, testsOnly: false }); }); test("i18n tooling/scripts → i18n + code (tooling can break runtime paths)", () => { @@ -49,7 +49,7 @@ test("workflow change → workflow + code (gates protect the gates)", () => { test("production source → code", () => { const c = classifyPaths(["open-sse/handlers/chatCore.ts", "src/lib/db/core.ts"]); - assert.deepEqual(c, { code: true, docs: false, i18n: false, workflow: false }); + assert.deepEqual(c, { code: true, docs: false, i18n: false, workflow: false, testsOnly: false }); }); test("mixed docs + code → both flags (jobs union their filters)", () => { @@ -65,5 +65,31 @@ test("unknown path → code fail-safe (never skip heavy gates by accident)", () test("empty change list → all false (nothing to validate)", () => { const c = classifyPaths([]); - assert.deepEqual(c, { code: false, docs: false, i18n: false, workflow: false }); + assert.deepEqual(c, { code: false, docs: false, i18n: false, workflow: false, testsOnly: false }); +}); + +// WS3.1 (v3.8.49 quality plan) — testsOnly powers the hotfix/test-only fast lane: +// a diff touching ONLY tests/ (and no tests/e2e/ spec) does not change the served +// app, so the 9-shard E2E matrix adds wall-time without coverage. e2e specs are +// excluded from the shortcut — changing an e2e spec REQUIRES running e2e. + +test("testsOnly: pure unit-test diff → true (still code)", () => { + const c = classifyPaths(["tests/unit/foo.test.ts", "tests/integration/bar.test.ts"]); + assert.equal(c.testsOnly, true); + assert.equal(c.code, true); +}); + +test("testsOnly: any non-test file flips it false", () => { + const c = classifyPaths(["tests/unit/foo.test.ts", "src/lib/db/core.ts"]); + assert.equal(c.testsOnly, false); +}); + +test("testsOnly: touching an e2e spec is NOT tests-only (e2e must run)", () => { + const c = classifyPaths(["tests/e2e/login.spec.ts"]); + assert.equal(c.testsOnly, false); +}); + +test("testsOnly: empty change list → false (fail-safe)", () => { + const c = classifyPaths([]); + assert.equal(c.testsOnly, false); }); diff --git a/tests/unit/claude-to-openai-system-role-6954.test.ts b/tests/unit/claude-to-openai-system-role-6954.test.ts new file mode 100644 index 0000000000..d9c48ac085 --- /dev/null +++ b/tests/unit/claude-to-openai-system-role-6954.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for #6954 — mid-conversation system turns misattributed as assistant. + * + * `convertClaudeMessage` mapped any role that wasn't "user" or "tool" to + * "assistant", so a Claude message with `role: "system"` (e.g. an injected + * system reminder mid-conversation) was forwarded to OpenAI-format upstreams + * as an assistant turn — polluting the conversation history. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { claudeToOpenAIRequest } = + await import("../../open-sse/translator/request/claude-to-openai.ts"); + +// --------------------------------------------------------------------------- +// 1. system message mid-conversation keeps role: "system" +// --------------------------------------------------------------------------- +test("mid-conversation system message preserves role:system (not assistant)", () => { + const result = claudeToOpenAIRequest( + "gpt-4o", + { + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "hi" }, + { role: "system", content: "Reminder: be concise." }, + { role: "user", content: "ok" }, + ], + }, + false + ); + + const roles = result.messages.map((m: { role: string }) => m.role); + assert.deepEqual(roles, ["user", "assistant", "system", "user"]); +}); + +// --------------------------------------------------------------------------- +// 2. system message with array content keeps role: "system" +// --------------------------------------------------------------------------- +test("system message with array content preserves role:system", () => { + const result = claudeToOpenAIRequest( + "gpt-4o", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "system", + content: [{ type: "text", text: "System reminder text" }], + }, + ], + }, + false + ); + + const sysMsg = result.messages.find((m: { role: string }) => m.role === "system"); + assert.ok(sysMsg, "expected a system message in output"); + // Array content with text blocks is flattened to a string for system role + assert.equal( + typeof sysMsg.content === "string" ? sysMsg.content : JSON.stringify(sysMsg.content), + "System reminder text" + ); +}); + +// --------------------------------------------------------------------------- +// 3. top-level body.system still produces role: "system" (regression check) +// --------------------------------------------------------------------------- +test("body.system still produces role:system at index 0", () => { + const result = claudeToOpenAIRequest( + "gpt-4o", + { + system: "You are helpful.", + messages: [{ role: "user", content: "hi" }], + }, + false + ); + + assert.equal(result.messages[0].role, "system"); + assert.equal(result.messages[1].role, "user"); +}); + +// --------------------------------------------------------------------------- +// 4. assistant with tool_use still maps to assistant (regression check) +// --------------------------------------------------------------------------- +test("assistant role still maps to assistant (no regression)", () => { + const result = claudeToOpenAIRequest( + "gpt-4o", + { + messages: [ + { role: "user", content: "use the tool" }, + { + role: "assistant", + content: [ + { type: "text", text: "calling tool" }, + { type: "tool_use", id: "t1", name: "foo", input: {} }, + ], + }, + ], + }, + false + ); + + const roles = result.messages.map((m: { role: string }) => m.role); + assert.ok(roles.includes("assistant"), "assistant role must be preserved"); +}); diff --git a/tests/unit/cli-runtime.test.ts b/tests/unit/cli-runtime.test.ts index 46a3e3204c..293f6a3933 100644 --- a/tests/unit/cli-runtime.test.ts +++ b/tests/unit/cli-runtime.test.ts @@ -71,20 +71,60 @@ test("buildEnvWithRuntime preserva NODE_PATH existente", async () => { assert.ok(env.NODE_PATH.includes("/existing/path"), "NODE_PATH original deve ser preservado"); }); -test("isBetterSqliteBinaryValid detecta ELF magic bytes (Linux)", async () => { +test("isBetterSqliteBinaryValid rejeita binário com magic bytes válidos mas ABI incompatível (regressão #2493)", async () => { + // Regression for upstream 9router#2493: a binary that only "looks" native (correct ELF/Mach-O/PE + // header) but was built for a different Node ABI (NODE_MODULE_VERSION) must NOT be reported as + // valid — loading it crashes the process (segfault) instead of triggering a rebuild. const { getRuntimeNodeModules, isBetterSqliteBinaryValid } = await import("../../bin/cli/runtime/nativeDeps.mjs"); const nm = getRuntimeNodeModules(); const buildDir = join(nm, "better-sqlite3", "build", "Release"); mkdirSync(buildDir, { recursive: true }); const binary = join(buildDir, "better_sqlite3.node"); - const buf = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00, 0x00, 0x00]); + const { platform } = await import("node:os"); + const os = platform(); + // Correct file-format magic bytes for the current OS, but not a real, loadable native addon — + // this is exactly what the old magic-bytes-only check let through. + const magicByPlatform = { + linux: [0x7f, 0x45, 0x4c, 0x46], + darwin: [0xcf, 0xfa, 0xed, 0xfe], + win32: [0x4d, 0x5a], + }; + const magic = magicByPlatform[os] ?? magicByPlatform.linux; + const buf = Buffer.concat([Buffer.from(magic), Buffer.alloc(64, 0)]); writeFileSync(binary, buf); const result = isBetterSqliteBinaryValid(); - const { platform } = await import("node:os"); - if (platform() === "linux") { - assert.equal(result, true, "ELF magic bytes devem ser válidos no Linux"); + assert.equal( + result, + false, + "binário com header válido mas ABI/conteúdo incompatível deve ser inválido" + ); + rmSync(join(nm, "better-sqlite3"), { recursive: true, force: true }); +}); + +test("isBetterSqliteBinaryValid aceita um binário nativo real e carregável", async () => { + const { getRuntimeNodeModules, isBetterSqliteBinaryValid } = + await import("../../bin/cli/runtime/nativeDeps.mjs"); + const { existsSync, copyFileSync } = await import("node:fs"); + const realBinary = join( + process.cwd(), + "node_modules", + "better-sqlite3", + "build", + "Release", + "better_sqlite3.node" + ); + if (!existsSync(realBinary)) { + // Ambient runtime without a compiled better-sqlite3 binary — nothing to assert here. + return; } + const nm = getRuntimeNodeModules(); + const buildDir = join(nm, "better-sqlite3", "build", "Release"); + mkdirSync(buildDir, { recursive: true }); + const binary = join(buildDir, "better_sqlite3.node"); + copyFileSync(realBinary, binary); + const result = isBetterSqliteBinaryValid(); + assert.equal(result, true, "um binário real, compatível com o Node atual, deve ser válido"); rmSync(join(nm, "better-sqlite3"), { recursive: true, force: true }); }); diff --git a/tests/unit/cloudflare-ai-neuron-exhaustion-6980.test.ts b/tests/unit/cloudflare-ai-neuron-exhaustion-6980.test.ts new file mode 100644 index 0000000000..4df5826c59 --- /dev/null +++ b/tests/unit/cloudflare-ai-neuron-exhaustion-6980.test.ts @@ -0,0 +1,108 @@ +/** + * Issue #6980 — Cloudflare Workers AI daily neuron exhaustion 429 must be + * classified as quota_exhausted (not transient rate_limit). + * + * Two layers of defense: + * 1. Provider-specific rule in providerErrorRules.ts → getProviderErrorRuleMatch + * 2. Global QUOTA_PATTERNS in classify429.ts → looksLikeQuotaExhausted + * + * Without these, the 429 body "you have used up your daily free allocation of + * 10,000 neurons" matches no keyword, falls through to rate_limit (~60s cooldown), + * and the combo router keeps cycling through every cloudflare model on retry + * against a budget that only resets at UTC midnight. + */ + +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { + getProviderErrorRuleMatch, + providerRuleRegistry, +} from "../../open-sse/config/providerErrorRules.ts"; +import { classify429, looksLikeQuotaExhausted } from "../../src/shared/utils/classify429.ts"; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const CF_NEURON_BODY = + "you have used up your daily free allocation of 10,000 neurons, please upgrade to Cloudflare's Workers Paid plan"; + +const CF_NEURON_BODY_JSON = { + errors: [ + { + code: 4006, + message: + "you have used up your daily free allocation of 10,000 neurons, please upgrade to Cloudflare's Workers Paid plan", + }, + ], +}; + +// ─── Tests: provider-specific rule (primary path) ─────────────────────────── + +describe("#6980 provider rule: cloudflare-ai neuron exhaustion", () => { + test("cloudflare-ai is registered in providerRuleRegistry", () => { + assert.ok(providerRuleRegistry.has("cloudflare-ai")); + }); + + test("429 with plain-string neuron body → quota_exhausted, scope connection", () => { + const result = getProviderErrorRuleMatch("cloudflare-ai", 429, {}, CF_NEURON_BODY); + assert.ok(result, "expected a match"); + assert.equal(result!.reason, "quota_exhausted"); + assert.equal(result!.scope, "connection"); + // No explicit cooldownMs — recordModelLockoutFailure resolves to next UTC midnight. + assert.equal(result!.cooldownMs, undefined); + }); + + test("429 with JSON-structured neuron body → quota_exhausted", () => { + const result = getProviderErrorRuleMatch("cloudflare-ai", 429, {}, CF_NEURON_BODY_JSON); + assert.ok(result); + assert.equal(result!.reason, "quota_exhausted"); + assert.equal(result!.scope, "connection"); + }); + + test("non-429 status does not match even with neuron body", () => { + const result = getProviderErrorRuleMatch("cloudflare-ai", 500, {}, CF_NEURON_BODY); + assert.equal(result, null); + }); + + test("429 with unrelated body does not match", () => { + const result = getProviderErrorRuleMatch( + "cloudflare-ai", + 429, + {}, + { + error: "rate limited, try again later", + } + ); + assert.equal(result, null); + }); + + test("provider name matching is case-insensitive", () => { + const result = getProviderErrorRuleMatch("Cloudflare-AI", 429, {}, CF_NEURON_BODY); + assert.ok(result); + assert.equal(result!.reason, "quota_exhausted"); + }); +}); + +// ─── Tests: classify429 defense-in-depth (fallback path) ──────────────────── + +describe("#6980 classify429: daily free allocation pattern", () => { + test("looksLikeQuotaExhausted matches neuron body string", () => { + assert.ok(looksLikeQuotaExhausted(CF_NEURON_BODY)); + }); + + test("looksLikeQuotaExhausted matches neuron body JSON-stringified", () => { + assert.ok(looksLikeQuotaExhausted(CF_NEURON_BODY_JSON)); + }); + + test("classify429 returns quota_exhausted for neuron body", () => { + assert.equal(classify429({ status: 429, body: CF_NEURON_BODY }), "quota_exhausted"); + }); + + test("classify429 returns quota_exhausted for neuron JSON body", () => { + assert.equal(classify429({ status: 429, body: CF_NEURON_BODY_JSON }), "quota_exhausted"); + }); + + test("classify429 returns rate_limit for generic 429 without quota keywords", () => { + assert.equal(classify429({ status: 429, body: "Too many requests" }), "rate_limit"); + }); +}); diff --git a/tests/unit/codex-tools-regex-lookaround.test.ts b/tests/unit/codex-tools-regex-lookaround.test.ts new file mode 100644 index 0000000000..4778ec7ac7 --- /dev/null +++ b/tests/unit/codex-tools-regex-lookaround.test.ts @@ -0,0 +1,74 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { normalizeCodexTools } from "../../open-sse/executors/codex/tools.ts"; + +// Port of 9router#1556: OpenAI/Codex Responses API rejects JSON Schema `pattern` +// fields containing regex lookaround (lookahead/lookbehind) with: +// "Invalid JSON schema: regex lookaround is not supported. Found at $.properties.email.pattern." +// Clients (e.g. IDE agent harnesses) commonly emit lookahead patterns such as +// `^(?=.*@).+$` for "must contain an @". These must be stripped before the +// tool schema reaches the Codex/OpenAI Responses API. +test("normalizeCodexTools strips regex lookaround from function tool parameter patterns", () => { + const body: Record = { + tools: [ + { + type: "function", + function: { + name: "send_email", + description: "Send an email", + parameters: { + type: "object", + properties: { + email: { + type: "string", + pattern: "^(?=.*@).+$", + }, + }, + }, + }, + }, + ], + }; + + normalizeCodexTools(body); + + const tools = body.tools as Array>; + const parameters = tools[0].parameters as Record; + const properties = parameters.properties as Record; + const emailSchema = properties.email as Record; + + assert.equal( + emailSchema.pattern, + undefined, + "lookaround pattern must be stripped, not forwarded upstream" + ); +}); + +test("normalizeCodexTools preserves plain (non-lookaround) regex patterns", () => { + const body: Record = { + tools: [ + { + type: "function", + function: { + name: "send_email", + parameters: { + type: "object", + properties: { + zip: { type: "string", pattern: "^[0-9]{5}$" }, + }, + }, + }, + }, + ], + }; + + normalizeCodexTools(body); + + const tools = body.tools as Array>; + const parameters = tools[0].parameters as Record; + const properties = parameters.properties as Record; + const zipSchema = properties.zip as Record; + + assert.equal(zipSchema.pattern, "^[0-9]{5}$"); +}); diff --git a/tests/unit/combo-diagnostics-trace.test.ts b/tests/unit/combo-diagnostics-trace.test.ts index be25e64c77..fe8bb546a4 100644 --- a/tests/unit/combo-diagnostics-trace.test.ts +++ b/tests/unit/combo-diagnostics-trace.test.ts @@ -79,3 +79,41 @@ test("combo diagnostics: secret containment — non-whitelisted fields never sur assert.ok(!serialized.includes("accessToken"), "no accessToken KEY survives"); assert.ok(!serialized.includes("token"), "no token KEY survives"); }); + +test("combo diagnostics: terminalReason with a non-Latin1 char (em dash) must not crash Response construction (#6612)", () => { + const terminalReason = "reasoning consumed 5/5 tokens — no content output"; + assert.doesNotThrow(() => { + const res = errorResponseWithComboDiagnostics( + 502, + `Upstream response failed quality validation: ${terminalReason}`, + { + poolSize: 4, + attempted: 1, + excluded: [{ provider: "deepseek", model: "deepseek-v4-flash-free", reason: "quality — bad" }], + attemptOrder: [{ provider: "deepseek", model: "deepseek-v4-flash-free" }], + terminalReason, + } + ); + assert.equal(res.status, 502); + }); +}); + +test("combo diagnostics: JSON body keeps the original non-Latin1 text even though headers are ASCII-sanitized (#6612)", async () => { + const terminalReason = "reasoning consumed 5/5 tokens — no content output"; + const res = errorResponseWithComboDiagnostics( + 502, + `Upstream response failed quality validation: ${terminalReason}`, + { + poolSize: 1, + attempted: 1, + excluded: [], + attemptOrder: [{ provider: "deepseek", model: "deepseek-v4-flash-free" }], + terminalReason, + } + ); + // Header value must be a valid Latin1 ByteString — em dash (U+2014) replaced. + assert.equal(res.headers.get("x-omniroute-combo-terminal-reason"), terminalReason.replace("—", "?")); + const body = await res.json(); + // JSON body keeps the original, readable (unsanitized) em dash. + assert.equal(body.diagnostics.terminalReason, terminalReason); +}); diff --git a/tests/unit/combo-proxy-assignments-parse-7149.test.ts b/tests/unit/combo-proxy-assignments-parse-7149.test.ts new file mode 100644 index 0000000000..7afb82b487 --- /dev/null +++ b/tests/unit/combo-proxy-assignments-parse-7149.test.ts @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseComboProxyAssignmentIds } from "../../src/app/(dashboard)/dashboard/combos/useComboProxyAssignments.ts"; + +test("#7149: parseComboProxyAssignmentIds extracts scopeIds from valid combo assignments", () => { + const data = { + items: [ + { scopeId: "combo-1", proxyId: "proxy-1", scope: "combo" }, + { scopeId: "combo-2", proxyId: "proxy-2", scope: "combo" }, + ], + }; + assert.deepEqual(parseComboProxyAssignmentIds(data), ["combo-1", "combo-2"]); +}); + +test("#7149: parseComboProxyAssignmentIds drops entries missing scopeId or proxyId", () => { + const data = { + items: [ + { scopeId: "combo-1", proxyId: "proxy-1" }, + { scopeId: "combo-2", proxyId: null }, + { scopeId: null, proxyId: "proxy-3" }, + {}, + ], + }; + assert.deepEqual(parseComboProxyAssignmentIds(data), ["combo-1"]); +}); + +test("#7149: parseComboProxyAssignmentIds returns [] for missing/malformed items", () => { + assert.deepEqual(parseComboProxyAssignmentIds(null), []); + assert.deepEqual(parseComboProxyAssignmentIds(undefined), []); + assert.deepEqual(parseComboProxyAssignmentIds({}), []); + assert.deepEqual(parseComboProxyAssignmentIds({ items: "not-an-array" }), []); +}); diff --git a/tests/unit/combo-scope-proxy-dead-7149.test.ts b/tests/unit/combo-scope-proxy-dead-7149.test.ts new file mode 100644 index 0000000000..8d301763e9 --- /dev/null +++ b/tests/unit/combo-scope-proxy-dead-7149.test.ts @@ -0,0 +1,87 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-proxy-7149-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); + +type ProxyResolutionLike = { + proxy?: { host?: string } | null; + level?: string; + levelId?: string | null; +} | null; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#7149: a proxy assigned to a Combo via the dashboard (registry scope='combo') is honored when resolving the proxy for a request routed through that combo", async () => { + await resetStorage(); + + const comboProxy = await proxiesDb.createProxy({ + name: "Combo-Assigned Proxy", + type: "http", + host: "10.20.30.40", + port: 8888, + }); + assert.ok(comboProxy?.id); + + const combo = await combosDb.createCombo({ + name: "diy_deepseek-v4-flash", + strategy: "round-robin", + models: ["openai/gpt-4"], + }); + const comboRecord = combo as Record; + assert.ok(comboRecord?.id); + const comboId = comboRecord.id as string; + + const assignment = await proxiesDb.assignProxyToScope("combo", comboId, comboProxy!.id); + assert.ok(assignment, "assignProxyToScope('combo', ...) should persist the assignment"); + + const directRegistryLookup = (await proxiesDb.resolveProxyForScopeFromRegistry( + "combo", + comboId + )) as ProxyResolutionLike; + assert.ok( + directRegistryLookup?.proxy, + "the registry must be able to answer a direct combo-scope lookup" + ); + assert.equal(directRegistryLookup?.proxy?.host, "10.20.30.40"); + + const connection = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "sk-test-1234", + name: "openai-account-1", + }); + const connectionRecord = connection as Record | null; + const connectionId = connectionRecord?.id as string; + assert.ok(connectionId, "test setup requires a real connection id"); + + const resolved = (await settingsDb.resolveProxyForConnection( + connectionId + )) as ProxyResolutionLike; + + assert.equal( + resolved?.level, + "combo", + `expected the combo-assigned proxy to be resolved (level="combo"), got level="${resolved?.level}" — the registry-based combo proxy assignment is never consulted by resolveProxyForConnection()` + ); + assert.equal(resolved?.proxy?.host, "10.20.30.40"); +}); diff --git a/tests/unit/composer-tool-calls.test.ts b/tests/unit/composer-tool-calls.test.ts index c1133f0513..5fba8abd3a 100644 --- a/tests/unit/composer-tool-calls.test.ts +++ b/tests/unit/composer-tool-calls.test.ts @@ -214,3 +214,27 @@ test("feedStreamingChunk: noop after done state", () => { assert.equal(out.safeDelta, ""); assert.equal(out.ready, false); }); + +// ─── Regression: space-separated arg name/value (9router#1811) ─────────────── +// Cursor's real Composer/Auto output has been observed using a single space +// (instead of a newline) between the arg name and its value inside a +// <|tool▁sep|> segment, e.g. "<|tool▁sep|>path /Users/.../test". The parser +// must still extract {path: "/Users/.../test"} rather than treating the whole +// segment as the (empty-valued) arg name. +test("parseComposerToolCalls: parses args separated by a space instead of a newline (Cursor Composer live capture)", () => { + const text = + "<|tool▁calls▁begin|><|tool▁call▁begin|> Write " + + "<|tool▁sep|>path /Users/kabawagang/Desktop/Code/iOS_Review/test " + + "<|tool▁sep|>contents 22\n\n<|tool▁call▁end|><|tool▁calls▁end|>"; + + const result = parseComposerToolCalls(text); + + assert.equal(result.toolCalls.length, 1); + const tc = result.toolCalls[0]; + assert.equal(tc.function.name, "Write"); + const args = JSON.parse(tc.function.arguments); + assert.deepEqual(args, { + path: "/Users/kabawagang/Desktop/Code/iOS_Review/test", + contents: 22, + }); +}); diff --git a/tests/unit/compression/adaptive-context-budget-config.test.ts b/tests/unit/compression/adaptive-context-budget-config.test.ts new file mode 100644 index 0000000000..4dd4acb68c --- /dev/null +++ b/tests/unit/compression/adaptive-context-budget-config.test.ts @@ -0,0 +1,84 @@ +// Regression test for #7005 — adaptive context-budget dial not configurable. +// +// The compute engine for the adaptive context-budget ("dial") shipped in PR #4716 +// (Phase 4C), but it was never wired to persistence or the API: the PUT schema +// rejected any `contextBudget` payload (strict schema, no such key) and the DB-backed +// GET path never surfaced a `contextBudget` field. This test proves both halves of +// the wiring: the Zod schema accepts a `contextBudget` write, and the DB read/write +// path round-trips it. +import { describe, it, beforeEach, afterEach, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-adaptive-context-budget-db-") +); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../../src/lib/db/core.ts"); +const { getCompressionSettings, updateCompressionSettings } = await import( + "../../../src/lib/db/compression.ts" +); +const { compressionSettingsUpdateSchema } = await import( + "../../../src/shared/validation/compressionConfigSchemas.ts" +); +const { DEFAULT_CONTEXT_BUDGET } = await import( + "../../../open-sse/services/compression/adaptiveCompression/types.ts" +); + +beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +afterEach(() => { + core.resetDbInstance(); +}); + +after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } +}); + +describe("bug #7005: adaptive context-budget dial is configurable", () => { + it("compressionSettingsUpdateSchema accepts a contextBudget write", () => { + const result = compressionSettingsUpdateSchema.safeParse({ + contextBudget: { + mode: "floor", + policy: "percentage", + outputReserve: 2048, + safetyMargin: 512, + pct: 0.75, + absoluteBudget: 0, + }, + }); + assert.equal(result.success, true, JSON.stringify("error" in result ? result.error : null)); + }); + + it("getCompressionSettings() defaults contextBudget to DEFAULT_CONTEXT_BUDGET when absent", async () => { + const settings = await getCompressionSettings(); + assert.deepEqual(settings.contextBudget, DEFAULT_CONTEXT_BUDGET); + }); + + it("updateCompressionSettings() persists a partial contextBudget merge", async () => { + await updateCompressionSettings({ + contextBudget: { ...DEFAULT_CONTEXT_BUDGET, mode: "floor", policy: "absolute", absoluteBudget: 8000 }, + }); + const settings = await getCompressionSettings(); + assert.equal(settings.contextBudget?.mode, "floor"); + assert.equal(settings.contextBudget?.policy, "absolute"); + assert.equal(settings.contextBudget?.absoluteBudget, 8000); + // Untouched fields keep their defaults (this is a JSON-column replace like ultra/aggressive, + // not a deep merge — the caller sends the full object, mirroring the existing pattern). + assert.equal(settings.contextBudget?.outputReserve, DEFAULT_CONTEXT_BUDGET.outputReserve); + }); +}); diff --git a/tests/unit/compression/headroom-developer-role-2132.test.ts b/tests/unit/compression/headroom-developer-role-2132.test.ts new file mode 100644 index 0000000000..5f797d3f1a --- /dev/null +++ b/tests/unit/compression/headroom-developer-role-2132.test.ts @@ -0,0 +1,91 @@ +/** + * Regression test for upstream 9router#2132 (ported): "Token saver Headroom ruins plan mode + * in Codex CLI". + * + * Root cause: SmartCrusher's system-message guard only checked `role === "system"`. Codex CLI + * (open-sse/executors/codex.ts) sends its instructions/tool-schema turn with role "developer" + * (the Responses-API equivalent of "system" used by newer models). Every other guard in this + * codebase that excludes "system" also excludes "developer" (see roleNormalizer.ts, + * contextManager.ts, claudeUpstreamMessages.ts, etc.) — SmartCrusher was the exception, so it + * happily tabular-compacted JSON arrays (e.g. the update_plan tool schema/examples) embedded in + * the developer-role turn, corrupting the instructions the model needs to call the plan tool. + */ + +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; + +let crushMessages: typeof import("../../../open-sse/services/compression/engines/headroom/smartcrusher.ts").crushMessages; +let collectCompactableArrays: typeof import("../../../open-sse/services/compression/engines/headroom/smartcrusher.ts").collectCompactableArrays; +let headroomEngine: import("../../../open-sse/services/compression/engines/headroom/index.ts").headroomEngine; + +before(async () => { + const mod = await import("../../../open-sse/services/compression/engines/headroom/smartcrusher.ts"); + crushMessages = mod.crushMessages; + collectCompactableArrays = mod.collectCompactableArrays; + + const engineMod = await import("../../../open-sse/services/compression/engines/headroom/index.ts"); + headroomEngine = engineMod.headroomEngine; +}); + +/** A homogeneous array big enough (>= default minRows=8) to trigger compaction. */ +function makePlanSchemaExample(): Record[] { + return Array.from({ length: 10 }, (_, i) => ({ + step: `step-${i + 1}`, + status: i === 0 ? "in_progress" : "pending", + })); +} + +describe("headroom SmartCrusher — developer-role guard (9router#2132)", () => { + it("does NOT compact JSON arrays embedded in a developer-role message (crushMessages)", () => { + const json = JSON.stringify(makePlanSchemaExample()); + const messages = [ + { + role: "developer", + content: `Use the update_plan tool. Example plan:\n\`\`\`json\n${json}\n\`\`\``, + }, + { role: "user", content: "Refactor the auth module." }, + ]; + + const { messages: result, changed } = crushMessages(messages, 8); + + assert.equal(changed, false, "developer-role content must not be touched"); + assert.equal(result[0].content, messages[0].content); + }); + + it("still compacts the same payload when placed under role: system (control case)", () => { + // Sanity check: this proves the array itself WOULD be compactable — the guard, not the + // shape of the payload, is what must change. + const json = JSON.stringify(makePlanSchemaExample()); + const messages = [{ role: "user", content: `\`\`\`json\n${json}\n\`\`\`` }]; + + const { changed } = crushMessages(messages, 8); + assert.equal(changed, true, "control case: user-role content of the same shape IS compacted"); + }); + + it("collectCompactableArrays does not surface arrays from developer-role messages", () => { + const json = JSON.stringify(makePlanSchemaExample()); + const messages = [ + { role: "developer", content: `\`\`\`json\n${json}\n\`\`\`` }, + ]; + const found = collectCompactableArrays(messages, 8); + assert.equal(found.length, 0); + }); + + it("headroomEngine.apply leaves a Codex-CLI-shaped developer turn untouched end-to-end", () => { + const json = JSON.stringify(makePlanSchemaExample()); + const body: Record = { + model: "gpt-5-codex", + messages: [ + { + role: "developer", + content: `Instructions with an embedded schema example:\n\`\`\`json\n${json}\n\`\`\``, + }, + { role: "user", content: "Implement the feature." }, + ], + }; + + const result = headroomEngine.apply(body); + assert.equal(result.compressed, false); + assert.deepEqual(result.body, body); + }); +}); diff --git a/tests/unit/duckduckgo-vqd-429-misclassification-6996.test.ts b/tests/unit/duckduckgo-vqd-429-misclassification-6996.test.ts new file mode 100644 index 0000000000..8b00ac5dc3 --- /dev/null +++ b/tests/unit/duckduckgo-vqd-429-misclassification-6996.test.ts @@ -0,0 +1,99 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-6996-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { DuckDuckGoWebExecutor, STATUS_URL } = await import( + "../../open-sse/executors/duckduckgo-web.ts" +); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); +const executeInputBase = { + model: "gpt-4o-mini", + body: { + model: "gpt-4o-mini", + messages: [{ role: "user", content: "hi" }], + stream: false, + }, + stream: false, + credentials: {}, +}; + +describe("#6996 DuckDuckGo VQD 429 misclassification", () => { + let originalFetch: typeof fetch; + + before(() => { + originalFetch = globalThis.fetch; + }); + + after(() => { + globalThis.fetch = originalFetch; + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + }); + + it("propagates upstream 429 instead of masking it as a generic 503", async () => { + // Set the mock AFTER the module import so it wins over + // open-sse/utils/proxyFetch.ts's own module-load-time + // `globalThis.fetch = patchedFetch` side effect. + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as URL | Request).toString(); + if (url === STATUS_URL) { + return new Response("", { + status: 429, + headers: { "Retry-After": "30" }, + }); + } + if (url.includes("/duckchat/v1/chat")) { + throw new Error("unexpected chat POST reached without a VQD token"); + } + return new Response("", { status: 200 }); + }) as typeof fetch; + + const executor = new DuckDuckGoWebExecutor(); + const response = await executor.execute(executeInputBase); + + const httpResponse = + response instanceof Response + ? response + : (response as { response: Response }).response; + const bodyText = await httpResponse.text(); + + assert.equal( + httpResponse.status, + 429, + `expected the executor to surface DuckDuckGo's real 429 rate-limit status, got ${httpResponse.status} (body: ${bodyText})` + ); + }); + + it("still returns 503 fallback for a genuine 5xx status on the VQD endpoint", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as URL | Request).toString(); + if (url === STATUS_URL) { + return new Response("", { status: 500 }); + } + if (url.includes("/duckchat/v1/chat")) { + throw new Error("unexpected chat POST reached without a VQD token"); + } + return new Response("", { status: 200 }); + }) as typeof fetch; + + const executor = new DuckDuckGoWebExecutor(); + const response = await executor.execute(executeInputBase); + + const httpResponse = + response instanceof Response + ? response + : (response as { response: Response }).response; + const bodyText = await httpResponse.text(); + + assert.equal( + httpResponse.status, + 503, + `expected the executor to keep the 503 fallback for a genuine upstream 5xx, got ${httpResponse.status} (body: ${bodyText})` + ); + }); +}); diff --git a/tests/unit/early-stream-keepalive.test.ts b/tests/unit/early-stream-keepalive.test.ts index a75c4c2764..5cd04ca192 100644 --- a/tests/unit/early-stream-keepalive.test.ts +++ b/tests/unit/early-stream-keepalive.test.ts @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { withEarlyStreamKeepalive, ANTHROPIC_PING_FRAME, + OPENAI_KEEPALIVE_FRAME, } from "../../open-sse/utils/earlyStreamKeepalive.ts"; async function readAll(response: Response): Promise { @@ -68,9 +69,40 @@ test("ANTHROPIC_PING_FRAME is a real Anthropic ping event (not a comment)", () = assert.doesNotMatch(decoded, /^:/, "must not be an SSE comment"); }); +test("OPENAI_KEEPALIVE_FRAME is a JSON-parseable OpenAI streaming chunk", () => { + const decoded = new TextDecoder().decode(OPENAI_KEEPALIVE_FRAME); + assert.match(decoded, /^data: /); + assert.doesNotMatch(decoded, /^:/, "must not be an SSE comment"); + + const payload = JSON.parse(decoded.slice("data: ".length).trim()); + assert.equal(payload.object, "chat.completion.chunk"); + assert.deepEqual(payload.choices, [{ index: 0, delta: {}, finish_reason: null }]); +}); + +test("slow handler emits the custom OpenAI keepalive chunk before the body", async () => { + const slow = new Promise((resolve) => { + setTimeout(() => resolve(sseResponse("data: [DONE]\n\n")), 120); + }); + + const result = await withEarlyStreamKeepalive(slow, { + thresholdMs: 25, + intervalMs: 20, + keepaliveFrame: OPENAI_KEEPALIVE_FRAME, + }); + + const body = await readAll(result); + assert.doesNotMatch(body, /: omniroute-keepalive/); + const firstFrame = body.split("\n\n")[0]; + assert.doesNotThrow(() => JSON.parse(firstFrame.slice("data: ".length))); + assert.match(body, /data: \[DONE\]/); +}); + test("slow handler emits the custom keepaliveFrame (Anthropic ping) before the body", async () => { const slow = new Promise((resolve) => { - setTimeout(() => resolve(sseResponse("event: message_start\ndata: {}\n\ndata: [DONE]\n\n")), 120); + setTimeout( + () => resolve(sseResponse("event: message_start\ndata: {}\n\ndata: [DONE]\n\n")), + 120 + ); }); const result = await withEarlyStreamKeepalive(slow, { diff --git a/tests/unit/free-pool-tab.test.tsx b/tests/unit/free-pool-tab.test.tsx index 6279174fa4..91ee249776 100644 --- a/tests/unit/free-pool-tab.test.tsx +++ b/tests/unit/free-pool-tab.test.tsx @@ -91,13 +91,13 @@ afterEach(() => { // ── Tests ───────────────────────────────────────────────────────────────────── describe("FreePoolTab source toggles", () => { - it("renders a toggle group with exactly 3 buttons", async () => { + it("renders a toggle group with exactly 4 buttons", async () => { const el = renderTab(); await waitForCondition(() => el.querySelector("[role='group']") !== null); const bar = el.querySelector("[role='group']")!; expect(bar).toBeTruthy(); const buttons = bar.querySelectorAll("button"); - expect(buttons.length).toBe(3); + expect(buttons.length).toBe(4); }); it("all toggles start enabled (aria-pressed=true)", async () => { @@ -160,7 +160,7 @@ describe("FreePoolTab source toggles", () => { expect(stored).toContain("1proxy"); }); - it("button labels are 1proxy, Proxifly, IPLocate", async () => { + it("button labels are 1proxy, Proxifly, IPLocate, Webshare", async () => { const el = renderTab(); await waitForCondition(() => el.querySelector("[role='group']") !== null); const texts = Array.from(el.querySelector("[role='group']")!.querySelectorAll("button")).map( @@ -169,6 +169,7 @@ describe("FreePoolTab source toggles", () => { expect(texts).toContain("1proxy"); expect(texts).toContain("Proxifly"); expect(texts).toContain("IPLocate"); + expect(texts).toContain("Webshare"); }); }); diff --git a/tests/unit/fusion-panel-size-cap-1905.test.ts b/tests/unit/fusion-panel-size-cap-1905.test.ts new file mode 100644 index 0000000000..6b9ac1b935 --- /dev/null +++ b/tests/unit/fusion-panel-size-cap-1905.test.ts @@ -0,0 +1,79 @@ +/** + * Regression test for upstream issue decolua/9router#1905. + * + * Reported symptom: a fusion combo populated with ~70+ panel models fans every + * member out in parallel (`open-sse/services/fusion.ts::handleFusionChat` → + * `Promise.all`-style fan-out via `collectPanel`), buffering each model's full + * response text in memory at once. With the runtime heap capped at 1024MB + * (Dockerfile `OMNIROUTE_MEMORY_MB`), a large panel with sizable concurrent + * responses can exceed the heap ceiling and crash the whole container with + * "FATAL ERROR: Ineffective mark-compacts near heap limit — JavaScript heap + * out of memory" instead of failing one request gracefully. + * + * Fix: `handleFusionChat` now rejects panels above a configurable hard cap + * (`FUSION_DEFAULTS.maxPanel`, overridable via `fusionTuning.maxPanel`) with a + * clean 400 *before* fan-out, rather than let an unbounded panel size drive + * the process into an OOM crash. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { handleFusionChat, FUSION_DEFAULTS } from "../../open-sse/services/fusion.ts"; + +const noop = () => {}; +const log = { info: noop, warn: noop, debug: noop, error: noop }; + +type Body = Record; + +test("fusion #1905: an oversized panel (73 models) is rejected before fan-out instead of OOM-crashing", async () => { + let calls = 0; + const handleSingleModel = (_b: Body, _m: string) => { + calls++; + const body = JSON.stringify({ + choices: [{ message: { role: "assistant", content: "x".repeat(1000) } }], + }); + return Promise.resolve( + new Response(body, { status: 200, headers: { "Content-Type": "application/json" } }) + ); + }; + + const panel = Array.from({ length: 73 }, (_, i) => `provider/model-${i}`); + + const res = await handleFusionChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + models: panel, + handleSingleModel, + log, + comboName: "auto", + }); + + assert.equal(res.status, 400); + // Must reject BEFORE fan-out — no per-model calls should have happened. + assert.equal(calls, 0, "panel fan-out must not start once the size cap is exceeded"); + + const json = (await res.json()) as { error?: { message?: string } }; + assert.match(json.error?.message ?? "", /panel/i); +}); + +test("fusion #1905: a panel at or under the cap still fans out normally", async () => { + const handleSingleModel = (_b: Body, _m: string) => { + const body = JSON.stringify({ + choices: [{ message: { role: "assistant", content: "ok" } }], + }); + return Promise.resolve( + new Response(body, { status: 200, headers: { "Content-Type": "application/json" } }) + ); + }; + + const panel = Array.from({ length: FUSION_DEFAULTS.maxPanel }, (_, i) => `provider/model-${i}`); + + const res = await handleFusionChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + models: panel, + handleSingleModel, + log, + comboName: "auto", + }); + + assert.equal(res.status, 200); +}); diff --git a/tests/unit/gemini-web.test.ts b/tests/unit/gemini-web.test.ts index 5e4a25d0cf..8ae1fe9675 100644 --- a/tests/unit/gemini-web.test.ts +++ b/tests/unit/gemini-web.test.ts @@ -266,15 +266,20 @@ test("#2832: GeminiWebExecutor catch block sanitizes Playwright launch errors (i // ─── StreamGenerate parsing ───────────────────────────────────────────────── -test("parseStreamResponse concatenates Gemini Web text from multiple wrb.fr chunks", () => { +test("parseStreamResponse keeps only the final cumulative StreamGenerate snapshot (no duplication) — regression for #7163", () => { const makeChunk = (text: string) => { const inner = new Array(80).fill(null); inner[4] = [[null, [text]]]; return `[["wrb.fr", null, ${JSON.stringify(JSON.stringify(inner))}]]`; }; - const raw = `)]}'\n10\n${makeChunk("First ")}\n5\n${makeChunk("chunk")}`; - assert.equal(parseStreamResponse(raw), "First chunk"); + // Gemini's StreamGenerate frames are CUMULATIVE snapshots: each later frame + // repeats the full answer generated so far, not just the new characters. + const frame1 = "Hello!"; + const frame2 = "Hello! How can I"; + const frame3 = "Hello! How can I help you out today?"; + const raw = `)]}'\n10\n${makeChunk(frame1)}\n5\n${makeChunk(frame2)}\n5\n${makeChunk(frame3)}`; + assert.equal(parseStreamResponse(raw), frame3); }); test("parseStreamResponse ignores wrb.fr lines whose first entry is not an array", () => { diff --git a/tests/unit/homolog-admin-client.test.ts b/tests/unit/homolog-admin-client.test.ts new file mode 100644 index 0000000000..225f66073c --- /dev/null +++ b/tests/unit/homolog-admin-client.test.ts @@ -0,0 +1,17 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { extractJwtCookie, extractApiKey } from "../../scripts/homolog/lib/adminClient.mjs"; + +test("extrai o cookie JWT do set-cookie do login", () => { + const jwt = extractJwtCookie(["auth_token=abc.def.ghi; Path=/; HttpOnly; SameSite=Lax"]); + assert.equal(jwt, "auth_token=abc.def.ghi"); +}); + +test("retorna null sem set-cookie de token", () => { + assert.equal(extractJwtCookie(["other=1; Path=/"]), null); +}); + +test("extrai key e id do POST /api/keys", () => { + const r = extractApiKey({ key: "or-abc123", id: "k1", name: "homolog-run" }); + assert.deepEqual(r, { key: "or-abc123", id: "k1" }); +}); diff --git a/tests/unit/homolog-parity.test.ts b/tests/unit/homolog-parity.test.ts new file mode 100644 index 0000000000..0c3d779f41 --- /dev/null +++ b/tests/unit/homolog-parity.test.ts @@ -0,0 +1,29 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { evaluateParity } from "../../scripts/homolog/lib/parity.mjs"; + +test("parity OK quando health bate com a versão esperada", () => { + const r = evaluateParity( + { status: "healthy", version: "3.8.49" }, + { expectedVersion: "3.8.49", httpStatus: 200 } + ); + assert.equal(r.ok, true); + assert.deepEqual(r.failures, []); +}); + +test("parity falha listando cada divergência", () => { + const r = evaluateParity( + { status: "degraded", version: "3.8.47" }, + { expectedVersion: "3.8.49", httpStatus: 200 } + ); + assert.equal(r.ok, false); + assert.equal(r.failures.length, 2); // status!=healthy, version mismatch +}); + +test("parity falha em HTTP não-200 mesmo com body bom", () => { + const r = evaluateParity( + { status: "healthy", version: "3.8.49" }, + { expectedVersion: "3.8.49", httpStatus: 503 } + ); + assert.equal(r.ok, false); +}); diff --git a/tests/unit/homolog-promptfoo-ctrf.test.ts b/tests/unit/homolog-promptfoo-ctrf.test.ts new file mode 100644 index 0000000000..d22dd652ee --- /dev/null +++ b/tests/unit/homolog-promptfoo-ctrf.test.ts @@ -0,0 +1,18 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { promptfooToCtrf } from "../../scripts/homolog/lib/promptfooToCtrf.mjs"; + +test("mapeia resultados do promptfoo para tests CTRF", () => { + const ctrf = promptfooToCtrf({ + results: { + results: [ + { provider: { label: "openai" }, success: true, latencyMs: 812 }, + { provider: { label: "grok" }, success: false, latencyMs: 30000, error: "timeout" }, + ], + }, + }); + assert.equal(ctrf.results.summary.tests, 2); + assert.equal(ctrf.results.summary.passed, 1); + assert.equal(ctrf.results.tests[1].status, "failed"); + assert.equal(ctrf.results.tests[1].name, "provider-smoke: grok"); +}); diff --git a/tests/unit/homolog-provider-tiers.test.ts b/tests/unit/homolog-provider-tiers.test.ts new file mode 100644 index 0000000000..3c793da73f --- /dev/null +++ b/tests/unit/homolog-provider-tiers.test.ts @@ -0,0 +1,24 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { pickSmokeModels } from "../../scripts/homolog/lib/providerTiers.mjs"; + +const CATALOG = [ + { id: "openai/gpt-5-mini" }, + { id: "openai/gpt-5" }, + { id: "anthropic/claude-sonnet-5" }, + { id: "mistral/mistral-small" }, + { id: "grok/grok-4-fast" }, +]; + +test("1 modelo por provider crítico (o primeiro do catálogo)", () => { + const picks = pickSmokeModels(CATALOG, ["openai", "anthropic", "grok"]); + assert.deepEqual( + picks.map((p) => p.model), + ["openai/gpt-5-mini", "anthropic/claude-sonnet-5", "grok/grok-4-fast"] + ); +}); + +test("provider crítico ausente do catálogo vira miss reportável", () => { + const picks = pickSmokeModels(CATALOG, ["openai", "nvidia"]); + assert.equal(picks.find((p) => p.provider === "nvidia").model, null); +}); diff --git a/tests/unit/homolog-sse-parser.test.ts b/tests/unit/homolog-sse-parser.test.ts new file mode 100644 index 0000000000..b51c226385 --- /dev/null +++ b/tests/unit/homolog-sse-parser.test.ts @@ -0,0 +1,28 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseSseChunk, summarizeStream } from "../../scripts/homolog/lib/sseCheck.mjs"; + +test("parseSseChunk separa eventos data: e detecta [DONE]", () => { + const events = parseSseChunk('data: {"choices":[{"delta":{"content":"O"}}]}\n\ndata: [DONE]\n\n'); + assert.equal(events.length, 2); + assert.equal(events[1], "[DONE]"); +}); + +test("parseSseChunk acha data: mesmo precedido de comment-lines SSE no mesmo bloco", () => { + // Formato real da VPS (v3.8.47): trailers de telemetria como comments (`: x-omniroute-*`) + // no MESMO bloco do data: [DONE] — o parser não pode olhar só o início do bloco. + const chunk = + 'data: {"choices":[{"delta":{"content":"OK"}}]}\n\n' + + ": x-omniroute-cache-hit=false\n: x-omniroute-latency-ms=67\ndata: [DONE]\n\n"; + const events = parseSseChunk(chunk); + assert.deepEqual(events, ['{"choices":[{"delta":{"content":"OK"}}]}', "[DONE]"]); +}); + +test("summarizeStream exige >=1 delta de conteúdo e terminador [DONE]", () => { + const good = summarizeStream(['{"choices":[{"delta":{"content":"OK"}}]}', "[DONE]"]); + assert.equal(good.ok, true); + const noDone = summarizeStream(['{"choices":[{"delta":{"content":"OK"}}]}']); + assert.equal(noDone.ok, false); + const noContent = summarizeStream(["[DONE]"]); + assert.equal(noContent.ok, false); +}); diff --git a/tests/unit/image-model-not-in-chat-catalog-6457.test.ts b/tests/unit/image-model-not-in-chat-catalog-6457.test.ts index cbd9b2033e..84187fe2f7 100644 --- a/tests/unit/image-model-not-in-chat-catalog-6457.test.ts +++ b/tests/unit/image-model-not-in-chat-catalog-6457.test.ts @@ -11,10 +11,10 @@ // `type: "image"` by the imageRegistry loop — and catalogDedupe.ts keys on // (id, type, subtype), so the two distinct-`type` entries both survived. // -// Fix: skip a synced model in the chat-catalog loop when it is already a registered -// image model for that exact provider (open-sse/config/imageRegistry.ts -// isRegisteredImageModel()) — the imageRegistry loop still adds the correctly-typed -// `type: "image"` entry. +// Fix: skip an exact-provider registered image model from the chat-catalog loop only +// when synced metadata does not explicitly advertise `chat` or `responses`. The image +// registry loop still adds the correctly typed image entry, while multi-capability +// models keep both entries. import test from "node:test"; import assert from "node:assert/strict"; @@ -38,6 +38,7 @@ async function resetStorage() { } test.beforeEach(async () => { + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); await resetStorage(); }); @@ -46,19 +47,19 @@ test.after(async () => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -async function seedHuggingFaceConnection() { +async function seedProviderConnection(provider: string) { return providersDb.createProviderConnection({ - provider: "huggingface", + provider, authType: "apikey", - name: `huggingface-${Math.random().toString(16).slice(2, 8)}`, - apiKey: "hf-key", + name: `${provider}-${Math.random().toString(16).slice(2, 8)}`, + apiKey: `${provider}-key`, isActive: true, testStatus: "active", }); } test("#6457 image/diffusion model discovered via live sync is NOT listed as a chat model", async () => { - const connection = await seedHuggingFaceConnection(); + const connection = await seedProviderConnection("huggingface"); // Simulate what HuggingFace's live `/v1/models` discovery persists for an // image/diffusion model: no supportedEndpoints/modality info at all — the exact @@ -100,3 +101,36 @@ test("#6457 image/diffusion model discovered via live sync is NOT listed as a ch assert.equal(entry.type, undefined, "the real chat model must not carry a non-chat type"); } }); + +test("registered image model with explicit chat endpoints keeps both catalog entries", async () => { + const connection = await seedProviderConnection("codex"); + + await modelsDb.replaceSyncedAvailableModelsForConnection("codex", connection.id, [ + { + id: "gpt-5.6-sol", + name: "GPT 5.6 Sol", + supportedEndpoints: ["responses"], + }, + ]); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models?prefix=alias") + ); + assert.equal(response.status, 200); + + const body = (await response.json()) as { + data: Array<{ id: string; type?: string; supported_endpoints?: string[] }>; + }; + const entries = body.data.filter((model) => model.id.endsWith("/gpt-5.6-sol")); + + assert.ok( + entries.some( + (model) => model.type !== "image" && model.supported_endpoints?.includes("responses") + ), + "explicit responses support must keep the synced chat entry" + ); + assert.ok( + entries.some((model) => model.type === "image"), + "the registered image entry must remain available under the same model id" + ); +}); diff --git a/tests/unit/issue-7071-ollama-session-quota.test.ts b/tests/unit/issue-7071-ollama-session-quota.test.ts new file mode 100644 index 0000000000..25b297c0ad --- /dev/null +++ b/tests/unit/issue-7071-ollama-session-quota.test.ts @@ -0,0 +1,103 @@ +/** + * Issue #7071 — Ollama Cloud's 5-hour "session" usage-limit 429 is never + * recognized as quota-exhausted. The upstream returns a body like: + * "you () have reached your session usage limit" + * + * This exactly mirrors the already-fixed "weekly usage limit" gap (#3709, + * #6638): ollama-cloud is an apikey-category provider (not oauth), so the + * oauth-only `shouldUseQuotaSignal` gate in checkFallbackError skips the + * generic subscription-quota-text branch (#2321) for its 429s. Without a + * dedicated, ungated session check the account fell through to the generic + * 429 backoff (~3s, capped low) and got retried within the same 5-hour + * session window instead of cooling down for the session's duration — + * combo/LKGP routing cycled back to the "exhausted" account instead of + * advancing to the next one. + * + * This test proves: (1) the session-usage-limit text is classified as + * QUOTA_EXHAUSTED with a cooldown far longer than the generic backoff cap, + * for BOTH apikey and oauth provider categories, and (2) unrelated + * session-expired/auth wording and the sibling weekly-quota text are + * unaffected. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts"); +const { isSessionUsageLimitText, buildSessionQuotaFallback, isWeeklyUsageLimitText } = + await import("../../open-sse/services/quotaTextCooldowns.ts"); +const { RateLimitReason, BACKOFF_CONFIG } = await import("../../open-sse/config/constants.ts"); +const { BACKOFF_CONFIG: ERROR_BACKOFF_CONFIG } = await import("../../open-sse/config/errorConfig.ts"); + +const SESSION_BODY = "you (acme-corp) have reached your session usage limit"; +const SESSION_COOLDOWN_MS = 5 * 60 * 60 * 1000; // 5 hours + +test("#7071 sanity: weekly text IS recognized (already fixed by #3709/#6638)", () => { + assert.equal(isWeeklyUsageLimitText("you (acme-corp) have reached your weekly usage limit"), true); +}); + +test("#7071 isSessionUsageLimitText matches the ollama-cloud 429 body", () => { + assert.equal(isSessionUsageLimitText(SESSION_BODY.toLowerCase()), true); + assert.equal(isSessionUsageLimitText("session limit reached, try later"), true); + assert.equal(isSessionUsageLimitText("rate_limit_exceeded: too many requests"), false); + // Must not false-positive on unrelated "session expired" auth errors. + assert.equal(isSessionUsageLimitText("your session has expired, please log in again"), false); + assert.equal(isSessionUsageLimitText("session token invalid"), false); +}); + +test("#7071 buildSessionQuotaFallback returns a 5h QUOTA_EXHAUSTED cooldown, far above the generic backoff cap", () => { + const result = buildSessionQuotaFallback(SESSION_BODY); + assert.ok(result, "expected a non-null fallback for session-usage-limit text"); + assert.equal(result!.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(result!.cooldownMs, SESSION_COOLDOWN_MS); + assert.ok(result!.cooldownMs > (ERROR_BACKOFF_CONFIG.max ?? BACKOFF_CONFIG.max)); +}); + +test("#7071 buildSessionQuotaFallback returns null for unrelated error text", () => { + assert.equal(buildSessionQuotaFallback("rate_limit_exceeded: too many requests"), null); + assert.equal(buildSessionQuotaFallback("your session has expired, please log in again"), null); +}); + +test("#7071 BUG: checkFallbackError misclassifies ollama-cloud session-quota 429 as generic RATE_LIMIT_EXCEEDED instead of QUOTA_EXHAUSTED", () => { + const out = checkFallbackError( + 429, + SESSION_BODY, + 0, // backoffLevel + null, // model + "ollama-cloud", // provider (apikey category) + null, // headers + null, // profileOverride + null // structuredError + ); + + assert.equal(out.shouldFallback, true); + assert.equal( + out.reason, + RateLimitReason.QUOTA_EXHAUSTED, + `expected QUOTA_EXHAUSTED for session-usage-limit text, got reason=${out.reason} cooldownMs=${out.cooldownMs}` + ); + assert.equal(out.cooldownMs, SESSION_COOLDOWN_MS); +}); + +test("#7071 checkFallbackError: oauth-category provider with session-limit text also gets the long cooldown", () => { + const out = checkFallbackError(429, SESSION_BODY, 0, null, "claude", null, null, null); + assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(out.cooldownMs, SESSION_COOLDOWN_MS); +}); + +test("#7071 checkFallbackError: ollama-cloud generic rate-limit body is unaffected (no false positive)", () => { + const out = checkFallbackError( + 429, + "rate_limit_exceeded: too many requests", + 0, + null, + "ollama-cloud", + null, + null, + null + ); + assert.equal(out.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); + assert.ok( + out.cooldownMs <= 2 * 60 * 1000, + "generic rate limit text must keep the normal short backoff, not the 5h session cooldown" + ); +}); diff --git a/tests/unit/listCapabilities-a2a.test.ts b/tests/unit/listCapabilities-a2a.test.ts index a51357d712..60e636b163 100644 --- a/tests/unit/listCapabilities-a2a.test.ts +++ b/tests/unit/listCapabilities-a2a.test.ts @@ -3,7 +3,7 @@ * * Verifies: * - Return shape matches §3.7 contract - * - Markdown table contains all 43 skill IDs + * - Markdown table contains all 44 skill IDs * - Coverage bounds are within declared totals * - metadata.source === "agent-skills-catalog" * - metadata.generatedAt is an ISO datetime string @@ -31,7 +31,7 @@ test("executeListCapabilities returns shape matching §3.7 contract", async () = const { metadata } = result; assert.ok(metadata, "metadata exists"); assert.equal(metadata.source, "agent-skills-catalog", "metadata.source matches"); - assert.equal(metadata.totalSkills, 44, "metadata.totalSkills === 44 (43 + config)"); + assert.equal(metadata.totalSkills, 45, "metadata.totalSkills === 45 (44 + config)"); assert.ok(metadata.coverage, "metadata.coverage exists"); assert.ok(metadata.coverage.api, "metadata.coverage.api exists"); assert.ok(metadata.coverage.cli, "metadata.coverage.cli exists"); @@ -39,12 +39,12 @@ test("executeListCapabilities returns shape matching §3.7 contract", async () = assert.equal(metadata.coverage.cli.total, 20, "cli.total === 20"); }); -test("executeListCapabilities markdown table contains all 43 API+CLI skill IDs", async () => { +test("executeListCapabilities markdown table contains all 44 API+CLI skill IDs", async () => { const result = await executeListCapabilities(stubTask); const content = result.artifacts[0].content; const allIds = [...API_SKILL_IDS, ...CLI_SKILL_IDS] as string[]; - assert.equal(allIds.length, 43, "API+CLI catalog declares 43 skill IDs"); + assert.equal(allIds.length, 44, "API+CLI catalog declares 44 skill IDs"); for (const id of allIds) { assert.ok(content.includes(id), `Markdown table missing skill ID: ${id}`); @@ -58,11 +58,11 @@ test("metadata.coverage.api.have is within [0, 23]", async () => { assert.ok(api.have <= 23, "api.have <= 23"); }); -test("metadata.coverage.cli.have is within [0, 20]", async () => { +test("metadata.coverage.cli.have is within [0, 21]", async () => { const result = await executeListCapabilities(stubTask); const { cli } = result.metadata.coverage; assert.ok(cli.have >= 0, "cli.have >= 0"); - assert.ok(cli.have <= 20, "cli.have <= 20"); + assert.ok(cli.have <= 21, "cli.have <= 21"); }); test("metadata.generatedAt is a valid ISO datetime", async () => { diff --git a/tests/unit/main-server-keepalive-timeout-7003.test.ts b/tests/unit/main-server-keepalive-timeout-7003.test.ts new file mode 100644 index 0000000000..fd20f19774 --- /dev/null +++ b/tests/unit/main-server-keepalive-timeout-7003.test.ts @@ -0,0 +1,202 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import net from "node:net"; +import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts"; + +// #7003 — JetBrains AI Assistant ("Test Connection" / completions) reported +// "HTTP/1.1 header parser received no bytes". The main OmniRoute server +// (scripts/dev/run-next.mjs) boots a bare `http.createServer(...)` and never +// configures `keepAliveTimeout`/`headersTimeout`, leaving Node's http.Server +// default of keepAliveTimeout=5_000ms with no `Keep-Alive: timeout=N` response +// hint. JetBrains AI Assistant's JVM `java.net.http.HttpClient` connection pool +// can reuse a socket idle for longer than that window; the server has already +// torn the socket down, so the client gets 0 response bytes back instead of a +// fresh HTTP response. +// +// This spec proves both halves: +// 1. `getMainServerTimeoutConfig()` raises the defaults well above Node's +// unconfigured 5_000ms window (the actual fix wired into run-next.mjs). +// 2. A bare http.Server left at Node's defaults drops a socket reused after +// an idle gap past 5s, while the same server configured via +// `getMainServerTimeoutConfig()` keeps serving the reused connection. + +describe("#7003 getMainServerTimeoutConfig", () => { + it("defaults keepAliveTimeout/headersTimeout well above Node's 5_000ms default", () => { + const config = getMainServerTimeoutConfig({}); + assert.equal(config.keepAliveTimeoutMs, 65_000); + assert.equal(config.headersTimeoutMs, 66_000); + assert.ok(config.keepAliveTimeoutMs > 5_000, "must exceed Node's unconfigured default"); + assert.ok( + config.headersTimeoutMs > config.keepAliveTimeoutMs, + "headersTimeout must stay above keepAliveTimeout per Node's own requirement" + ); + }); + + it("honors env overrides and keeps headersTimeout coherent with a raised keepAliveTimeout", () => { + const config = getMainServerTimeoutConfig({ + MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "120000", + MAIN_SERVER_HEADERS_TIMEOUT_MS: "121000", + }); + assert.equal(config.keepAliveTimeoutMs, 120_000); + assert.equal(config.headersTimeoutMs, 121_000); + }); + + it("bumps an inconsistent explicit headersTimeout override above keepAliveTimeout", () => { + const config = getMainServerTimeoutConfig({ + MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "120000", + MAIN_SERVER_HEADERS_TIMEOUT_MS: "1000", + }); + assert.equal(config.keepAliveTimeoutMs, 120_000); + assert.equal(config.headersTimeoutMs, 121_000); + }); + + it("falls back to defaults on invalid env values", () => { + const config = getMainServerTimeoutConfig({ + MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "not-a-number", + }); + assert.equal(config.keepAliveTimeoutMs, 65_000); + }); +}); + +/** + * Sends a raw HTTP/1.1 GET over an already-connected keep-alive socket and + * resolves with whatever bytes arrive within a short settle window (empty + * string if nothing comes back — the exact "0 bytes back" failure mode + * JetBrains AI Assistant surfaces as "header parser received no bytes"). + * + * The socket is opened with `allowHalfOpen: true` so it faithfully mimics a + * JVM/OkHttp-style client: Node's default `allowHalfOpen: false` proactively + * ends the writable side the instant it processes an incoming FIN, turning + * the reused write into a synchronous "socket has been ended" error instead + * of the real-world race — a write that is accepted locally (the server + * already destroyed the connection, so it never arrives) whose response + * settles as 0 bytes. + */ +function sendKeepAliveRequest(socket: net.Socket, port: number): Promise { + return new Promise((resolve) => { + let received = ""; + let settleTimer: NodeJS.Timeout; + const finish = () => { + socket.off("data", onData); + clearTimeout(settleTimer); + resolve(received); + }; + // A short settle window once the full chunked response has arrived (fast + // path); a generous cap in case nothing ever comes back — the torn-down + // connection case this test proves, and a safety margin against first-run + // JIT/module-load jitter under the test runner. + const onData = (chunk: Buffer) => { + received += chunk.toString("utf8"); + if (received.endsWith("0\r\n\r\n")) { + clearTimeout(settleTimer); + settleTimer = setTimeout(finish, 50); + } + }; + socket.on("data", onData); + socket.write(`GET / HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: keep-alive\r\n\r\n`); + settleTimer = setTimeout(finish, 3_000); + }); +} + +function startEchoServer(configure: (server: http.Server) => void): Promise { + return new Promise((resolve) => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("ok"); + }); + configure(server); + server.listen(0, "127.0.0.1", () => resolve(server)); + }); +} + +async function withServer( + configure: (server: http.Server) => void, + run: (port: number) => Promise +): Promise { + const server = await startEchoServer(configure); + try { + const address = server.address(); + if (typeof address !== "object" || address === null) { + throw new Error("expected server to bind a TCP address"); + } + await run(address.port); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +// Node's default keepAliveTimeout is 5_000ms, but the server only starts that +// timer once the response has fully flushed and there is a small amount of +// internal scheduling overhead before the socket is actually torn down — +// empirically ~5.8-6s end-to-end on loopback. 6.5s reliably clears that +// window without relying on a hair-trigger race. +const IDLE_GAP_MS = 6_500; + +describe("#7003 keep-alive socket reuse across an idle gap", () => { + it( + "current Node defaults (keepAliveTimeout=5000ms): a pooled socket reused after 6.5s idle gets 0 bytes back", + { timeout: 30_000 }, + async () => { + await withServer( + () => { + /* leave Node's http.Server defaults untouched (keepAliveTimeout=5000ms) */ + }, + async (port) => { + const socket = net.connect({ port, host: "127.0.0.1", allowHalfOpen: true }); + await new Promise((resolve, reject) => { + socket.once("connect", () => resolve()); + socket.once("error", reject); + }); + + const first = await sendKeepAliveRequest(socket, port); + assert.match(first, /200/, "first request on a fresh socket must succeed"); + + await new Promise((resolve) => setTimeout(resolve, IDLE_GAP_MS)); + + const second = await sendKeepAliveRequest(socket, port); + assert.equal( + second, + "", + "reusing the idle-torn-down socket must get exactly 0 bytes back (the reported bug)" + ); + socket.destroy(); + } + ); + } + ); + + it( + "fixed config (getMainServerTimeoutConfig): the same reused connection stays alive past 6.5s idle", + { timeout: 30_000 }, + async () => { + const fixedTimeouts = getMainServerTimeoutConfig({}); + await withServer( + (server) => { + server.keepAliveTimeout = fixedTimeouts.keepAliveTimeoutMs; + server.headersTimeout = fixedTimeouts.headersTimeoutMs; + }, + async (port) => { + const socket = net.connect({ port, host: "127.0.0.1", allowHalfOpen: true }); + await new Promise((resolve, reject) => { + socket.once("connect", () => resolve()); + socket.once("error", reject); + }); + + const first = await sendKeepAliveRequest(socket, port); + assert.match(first, /200/, "first request on a fresh socket must succeed"); + + await new Promise((resolve) => setTimeout(resolve, IDLE_GAP_MS)); + + const second = await sendKeepAliveRequest(socket, port); + assert.match( + second, + /200/, + "the reused connection must still get a valid response after the fix" + ); + socket.destroy(); + } + ); + } + ); +}); diff --git a/tests/unit/main-server-timeouts-parity.test.ts b/tests/unit/main-server-timeouts-parity.test.ts new file mode 100644 index 0000000000..7a8374c942 --- /dev/null +++ b/tests/unit/main-server-timeouts-parity.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert"; +import { getMainServerTimeoutConfig as mjsImpl } from "../../scripts/dev/main-server-timeouts.mjs"; +import { getMainServerTimeoutConfig as tsImpl } from "../../src/shared/utils/runtimeTimeouts.ts"; + +// The shipped server-ws.mjs uses the SIBLING scripts/dev/main-server-timeouts.mjs +// (a ../../src import escapes the package after the dist copy — 2026-07-15 boot +// crash, #7065 class). This parity matrix is the anti-drift guard between the +// sibling and the canonical src/shared/utils/runtimeTimeouts.ts implementation. +const ENV_MATRIX: Record[] = [ + {}, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "70000" }, + { MAIN_SERVER_HEADERS_TIMEOUT_MS: "80000" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "90000", MAIN_SERVER_HEADERS_TIMEOUT_MS: "10000" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "0", MAIN_SERVER_HEADERS_TIMEOUT_MS: "0" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "abc" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: " " }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "-5" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "1234.9" }, +]; + +test("sibling main-server-timeouts.mjs stays in parity with runtimeTimeouts.ts", () => { + for (const env of ENV_MATRIX) { + assert.deepStrictEqual( + mjsImpl(env), + tsImpl(env), + `divergence for env ${JSON.stringify(env)}` + ); + } +}); + +test("invalid values log through the provided logger in both implementations", () => { + const logsA: string[] = []; + const logsB: string[] = []; + mjsImpl({ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "bogus" }, (m) => logsA.push(m)); + tsImpl({ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "bogus" }, (m) => logsB.push(m)); + assert.strictEqual(logsA.length, 1); + assert.deepStrictEqual(logsA, logsB); +}); diff --git a/tests/unit/materialize-bundled-symlinks.test.ts b/tests/unit/materialize-bundled-symlinks.test.ts new file mode 100644 index 0000000000..a83dc980bd --- /dev/null +++ b/tests/unit/materialize-bundled-symlinks.test.ts @@ -0,0 +1,225 @@ +import assert from "node:assert/strict"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +// @ts-expect-error - JS build helper without type declarations +import { + materializeBundledSymlinks, + syncRebuiltNativeModuleIntoHashedEntries, +} from "../../scripts/build/assembleStandalone.mjs"; + +function makePkg(dir: string, name: string, marker: string) { + const pkgDir = join(dir, name); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name, marker })); + return pkgDir; +} + +test("materializeBundledSymlinks dereferences a live symlink into a real directory", () => { + const root = mkdtempSync(join(tmpdir(), "mbs-live-")); + try { + const realPkgHome = join(root, "external"); + mkdirSync(realPkgHome, { recursive: true }); + makePkg(realPkgHome, "ws", "real-ws"); + + const nm = join(root, "bundle", "node_modules"); + mkdirSync(nm, { recursive: true }); + symlinkSync(join(realPkgHome, "ws"), join(nm, "ws-a972e7ffa40ff725"), "dir"); + + const summary = materializeBundledSymlinks(nm); + + assert.equal(summary.materialized, 1); + const target = join(nm, "ws-a972e7ffa40ff725"); + assert.equal(lstatSync(target).isSymbolicLink(), false); + assert.equal(lstatSync(target).isDirectory(), true); + assert.equal(JSON.parse(readFileSync(join(target, "package.json"), "utf8")).marker, "real-ws"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("materializeBundledSymlinks relinks a dangling hashed symlink to its sibling real package", () => { + const root = mkdtempSync(join(tmpdir(), "mbs-dangle-")); + try { + const nm = join(root, "node_modules"); + mkdirSync(nm, { recursive: true }); + // Sibling real package (as copied by copyNativeAssetsAndExtraModules). + makePkg(nm, "better-sqlite3", "real-bsq"); + // Dangling absolute link into a build machine that does not exist here. + symlinkSync( + "/Users/runner/work/OmniRoute/OmniRoute/.build/next/standalone/node_modules/better-sqlite3", + join(nm, "better-sqlite3-90e2652d1716b047"), + "dir" + ); + + const summary = materializeBundledSymlinks(nm); + + assert.equal(summary.relinked, 1); + const target = join(nm, "better-sqlite3-90e2652d1716b047"); + assert.equal(lstatSync(target).isSymbolicLink(), false); + assert.equal(JSON.parse(readFileSync(join(target, "package.json"), "utf8")).marker, "real-bsq"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("materializeBundledSymlinks drops a dangling link with no resolvable sibling", () => { + const root = mkdtempSync(join(tmpdir(), "mbs-drop-")); + try { + const nm = join(root, "node_modules"); + mkdirSync(nm, { recursive: true }); + symlinkSync( + "/nonexistent/build/machine/path/mystery", + join(nm, "mystery-deadbeefcafe0001"), + "dir" + ); + + const summary = materializeBundledSymlinks(nm); + + assert.equal(summary.removed, 1); + assert.equal(existsSync(join(nm, "mystery-deadbeefcafe0001")), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("materializeBundledSymlinks handles scoped-package symlinks", () => { + const root = mkdtempSync(join(tmpdir(), "mbs-scope-")); + try { + const realPkgHome = join(root, "external"); + mkdirSync(join(realPkgHome, "@huggingface"), { recursive: true }); + makePkg(join(realPkgHome, "@huggingface"), "transformers", "real-hf"); + + const nm = join(root, "node_modules"); + mkdirSync(join(nm, "@huggingface"), { recursive: true }); + symlinkSync( + join(realPkgHome, "@huggingface", "transformers"), + join(nm, "@huggingface", "transformers-abc1234567890def"), + "dir" + ); + + const summary = materializeBundledSymlinks(nm); + + assert.equal(summary.materialized, 1); + const target = join(nm, "@huggingface", "transformers-abc1234567890def"); + assert.equal(lstatSync(target).isSymbolicLink(), false); + assert.equal(JSON.parse(readFileSync(join(target, "package.json"), "utf8")).marker, "real-hf"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("materializeBundledSymlinks leaves real directories untouched and no-ops on missing dir", () => { + const root = mkdtempSync(join(tmpdir(), "mbs-noop-")); + try { + const nm = join(root, "node_modules"); + mkdirSync(nm, { recursive: true }); + makePkg(nm, "pino", "real-pino"); + + const summary = materializeBundledSymlinks(nm); + assert.deepEqual(summary, { materialized: 0, relinked: 0, removed: 0 }); + assert.equal(existsSync(join(nm, "pino", "package.json")), true); + + const missing = materializeBundledSymlinks(join(root, "does-not-exist")); + assert.deepEqual(missing, { materialized: 0, relinked: 0, removed: 0 }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("syncRebuiltNativeModuleIntoHashedEntries overwrites a hashed entry with the rebuilt root module", () => { + const root = mkdtempSync(join(tmpdir(), "sync-hashed-")); + try { + const rootModule = makePkg(root, "better-sqlite3", "electron-abi-rebuilt"); + + const nm = join(root, "nested", "node_modules"); + makePkg(nm, "better-sqlite3-90e2652d1716b047", "stale-node-abi"); + + const summary = syncRebuiltNativeModuleIntoHashedEntries(rootModule, nm); + + assert.equal(summary.synced, 1); + const target = join(nm, "better-sqlite3-90e2652d1716b047"); + assert.equal( + JSON.parse(readFileSync(join(target, "package.json"), "utf8")).marker, + "electron-abi-rebuilt" + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("syncRebuiltNativeModuleIntoHashedEntries overwrites a plain-named entry too", () => { + const root = mkdtempSync(join(tmpdir(), "sync-plain-")); + try { + const rootModule = makePkg(root, "better-sqlite3", "electron-abi-rebuilt"); + + const nm = join(root, "nested", "node_modules"); + makePkg(nm, "better-sqlite3", "stale-node-abi"); + + const summary = syncRebuiltNativeModuleIntoHashedEntries(rootModule, nm); + + assert.equal(summary.synced, 1); + assert.equal( + JSON.parse(readFileSync(join(nm, "better-sqlite3", "package.json"), "utf8")).marker, + "electron-abi-rebuilt" + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("syncRebuiltNativeModuleIntoHashedEntries no-ops when root module or nested node_modules is missing", () => { + const root = mkdtempSync(join(tmpdir(), "sync-noop-")); + try { + const rootModule = join(root, "does-not-exist", "better-sqlite3"); + const nm = join(root, "nested", "node_modules"); + makePkg(nm, "better-sqlite3", "stale-node-abi"); + + const missingRoot = syncRebuiltNativeModuleIntoHashedEntries(rootModule, nm); + assert.deepEqual(missingRoot, { synced: 0 }); + + const realRootModule = makePkg(root, "better-sqlite3", "electron-abi-rebuilt"); + const missingNm = syncRebuiltNativeModuleIntoHashedEntries( + realRootModule, + join(root, "does-not-exist-nm") + ); + assert.deepEqual(missingNm, { synced: 0 }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("syncRebuiltNativeModuleIntoHashedEntries leaves unrelated entries untouched", () => { + const root = mkdtempSync(join(tmpdir(), "sync-unrelated-")); + try { + const rootModule = makePkg(root, "better-sqlite3", "electron-abi-rebuilt"); + + const nm = join(root, "nested", "node_modules"); + makePkg(nm, "pino", "real-pino"); + makePkg(nm, "better-sqlite3-helper", "unrelated-package"); + + const summary = syncRebuiltNativeModuleIntoHashedEntries(rootModule, nm); + + assert.deepEqual(summary, { synced: 0 }); + assert.equal( + JSON.parse(readFileSync(join(nm, "pino", "package.json"), "utf8")).marker, + "real-pino" + ); + assert.equal( + JSON.parse(readFileSync(join(nm, "better-sqlite3-helper", "package.json"), "utf8")).marker, + "unrelated-package" + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/minimax-m3-model-registry.test.ts b/tests/unit/minimax-m3-model-registry.test.ts index 820cbc77ca..2aae2b3a69 100644 --- a/tests/unit/minimax-m3-model-registry.test.ts +++ b/tests/unit/minimax-m3-model-registry.test.ts @@ -29,13 +29,11 @@ describe("MiniMax M3 model registration (#3110)", () => { assert.equal(m3.contextLength, 1_048_576); }); - it("opencode provider has minimax-m3-free with 1M context", () => { + it("opencode provider does NOT list minimax-m3-free (#6998 — delisted upstream, 401)", () => { const entry = REGISTRY.opencode; assert.ok(entry, "opencode registry entry must exist"); const m3 = entry.models.find((m) => m.id === "minimax-m3-free"); - assert.ok(m3, "minimax-m3-free must be in opencode models"); - assert.equal(m3.name, "MiniMax M3 Free"); - assert.equal(m3.contextLength, 1_048_576); + assert.equal(m3, undefined, "minimax-m3-free was delisted from OpenCode Zen's free tier (#6998)"); }); it("opencode-go provider has minimax-m3 with Claude targetFormat", () => { diff --git a/tests/unit/mitm-stop-dns-before-kill-1809.test.ts b/tests/unit/mitm-stop-dns-before-kill-1809.test.ts new file mode 100644 index 0000000000..3dd92dcaad --- /dev/null +++ b/tests/unit/mitm-stop-dns-before-kill-1809.test.ts @@ -0,0 +1,82 @@ +/** + * Regression test for upstream issue #1809: "connect ECONNREFUSED 127.0.0.1:443" + * after stopping the MITM proxy. + * + * Root cause: stopMitm() killed the spawned MITM server process FIRST, and only + * removed the /etc/hosts DNS-spoof entries AFTER. During that window any client + * whose DNS still resolved the target host to 127.0.0.1 (from startMitm's spoof) + * but whose MITM listener was already dead got ECONNREFUSED — exactly the + * community-confirmed workaround ("stop DNS before stopping the server") proves. + * + * This test drives stopMitm() with real DI: a fake serverProcess standing in for + * the spawned MITM child, and dependency-injected DNS-removal functions that + * record the order in which they are invoked relative to the process kill. The + * fix must remove DNS entries before killing the server process so no window + * exists where DNS points at 127.0.0.1 with nothing listening there. + * + * Uses the project's DATA_DIR-tmp + resetDbInstance pattern so the Node native + * test runner does not hang on open SQLite handles (CLAUDE.md PII learning #3). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { EventEmitter } from "node:events"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-mitm-stop-order-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const manager = await import("../../src/mitm/manager.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("stopMitm removes DNS entries before killing the MITM server process (#1809)", async () => { + const events: string[] = []; + + // Fake child process standing in for the spawned MITM server. + const fakeProc = new EventEmitter() as EventEmitter & { + killed: boolean; + kill: (signal?: string) => boolean; + }; + fakeProc.killed = false; + fakeProc.kill = (signal?: string) => { + events.push(`kill:${signal}`); + fakeProc.killed = true; + return true; + }; + + manager.__setServerProcessForTest(fakeProc as unknown as import("child_process").ChildProcess, 4242); + + const removeDNSEntry = async () => { + events.push("removeDNSEntry"); + }; + const removeDNSEntries = async () => { + events.push("removeDNSEntries"); + }; + const collectManagedHosts = () => ["fake.example.test"]; + + await manager.stopMitm("fake-sudo-password", { + removeDNSEntry, + removeDNSEntries, + collectManagedHosts, + }); + + const firstKillIndex = events.findIndex((e) => e.startsWith("kill:")); + const firstDnsIndex = events.findIndex( + (e) => e === "removeDNSEntry" || e === "removeDNSEntries" + ); + + assert.ok(firstKillIndex !== -1, "server process kill was never invoked"); + assert.ok(firstDnsIndex !== -1, "DNS removal was never invoked"); + assert.ok( + firstDnsIndex < firstKillIndex, + `DNS entries must be removed BEFORE the MITM server process is killed ` + + `(got order: ${JSON.stringify(events)}) — otherwise a client whose DNS still ` + + `points at 127.0.0.1 hits a dead listener and gets ECONNREFUSED (#1809)` + ); +}); diff --git a/tests/unit/next-config.test.ts b/tests/unit/next-config.test.ts index 29821fa4ec..9281175217 100644 --- a/tests/unit/next-config.test.ts +++ b/tests/unit/next-config.test.ts @@ -288,6 +288,30 @@ test("turbopack.ignoreIssue suppresses the agentSkills over-bundling warning (#6 assert.match(String(agentSkillsRule.description), /Overly broad patterns/); }); +test("turbopack.ignoreIssue suppresses the compression module over-bundling warning (#7051)", async () => { + // open-sse/services/compression/ruleLoader.ts and + // .../engines/rtk/filterLoader.ts both define an identical getModuleDir() + // helper that walks up directories via path.resolve(anchor) + + // fs.existsSync(...) in a loop with a non-literal argument — the same + // class of dynamic-path fs access that #6582 suppressed for + // src/lib/agentSkills/**, but that narrow allowlist glob didn't cover this + // module, so the warning kept firing (610 times) for every entry point + // transitively importing the compression module. This guards the config + // shape so the suppression rule isn't silently dropped in a future edit. + const { default: nextConfig } = await loadNextConfig("ignore-issue-compression"); + const rules = nextConfig.turbopack?.ignoreIssue; + + assert.ok(Array.isArray(rules), "expected turbopack.ignoreIssue to be an array"); + const compressionRule = rules.find((rule) => + String(rule.path).includes("open-sse/services/compression") + ); + assert.ok( + compressionRule, + "expected an ignoreIssue rule targeting open-sse/services/compression/**" + ); + assert.match(String(compressionRule.description), /Overly broad patterns/); +}); + test("optimizePackageImports excludes the internal @omniroute/open-sse workspace (build-OOM guard)", async () => { // Regression guard: adding the internal `@omniroute/open-sse` workspace to // optimizePackageImports makes Next.js resolve its entire barrel at build diff --git a/tests/unit/noAuthProxyResolution.relayAuth.test.ts b/tests/unit/noAuthProxyResolution.relayAuth.test.ts new file mode 100644 index 0000000000..f838038a0f --- /dev/null +++ b/tests/unit/noAuthProxyResolution.relayAuth.test.ts @@ -0,0 +1,49 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveAccountProxies } from "../../src/sse/services/noAuthProxyResolution.ts"; + +test("resolveAccountProxies preserves relayAuth for relay-type (vercel/deno/cloudflare) pool proxies", async () => { + const fakeVercelProxyRow = { + id: "proxy-1", + type: "vercel", + host: "my-relay-abc123.vercel.app", + port: 443, + username: null, + password: null, + notes: JSON.stringify({ relayAuth: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }), + }; + + const resolved = await resolveAccountProxies( + [{ fingerprint: "acct-1", proxyId: "proxy-1" }], + async (id) => (id === "proxy-1" ? fakeVercelProxyRow : null) + ); + + const proxy = resolved[0].proxy as unknown as { type?: string; relayAuth?: string }; + assert.equal(proxy?.type, "vercel"); + assert.equal( + proxy?.relayAuth, + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "relayAuth must survive resolveAccountProxies() for relay-type (vercel/deno/cloudflare) proxies" + ); +}); + +test("resolveAccountProxies leaves relayAuth absent for plain non-relay (socks5/http) pool proxies", async () => { + const fakeSocksProxyRow = { + id: "proxy-2", + type: "socks5", + host: "1.2.3.4", + port: 1080, + username: "u", + password: "p", + notes: JSON.stringify({ relayAuth: "should-not-leak-onto-non-relay-types" }), + }; + + const resolved = await resolveAccountProxies( + [{ fingerprint: "acct-2", proxyId: "proxy-2" }], + async (id) => (id === "proxy-2" ? fakeSocksProxyRow : null) + ); + + const proxy = resolved[0].proxy as unknown as { type?: string; relayAuth?: string }; + assert.equal(proxy?.type, "socks5"); + assert.equal(proxy?.relayAuth, undefined); +}); diff --git a/tests/unit/ollama-cloud-usage.test.ts b/tests/unit/ollama-cloud-usage.test.ts index 9c54789911..2037426daa 100644 --- a/tests/unit/ollama-cloud-usage.test.ts +++ b/tests/unit/ollama-cloud-usage.test.ts @@ -11,6 +11,28 @@ test("USAGE_SUPPORTED_PROVIDERS includes ollama-cloud", () => { ); }); +test("USAGE_FETCHER_PROVIDERS includes ollama-cloud (#7026)", () => { + // getUsageForProvider's switch handles `case "ollama-cloud"`, and the array's doc comment + // requires it to stay in sync with that switch. If it drifts, registerGenericQuotaFetchers + // never registers a preflight quota fetcher for ollama-cloud even though the scraper exists. + assert.ok( + (usage.USAGE_FETCHER_PROVIDERS as readonly string[]).includes("ollama-cloud"), + "ollama-cloud is handled by getUsageForProvider's switch and must be listed in USAGE_FETCHER_PROVIDERS" + ); +}); + +test("registerGenericQuotaFetchers wires a preflight quota fetcher for ollama-cloud (#7026)", async () => { + const { registerGenericQuotaFetchers } = await import( + "../../open-sse/services/genericQuotaFetcher.ts" + ); + const { getQuotaFetcher } = await import("../../open-sse/services/quotaPreflight.ts"); + registerGenericQuotaFetchers(); + assert.ok( + getQuotaFetcher("ollama-cloud"), + "a generic quota fetcher must be registered for ollama-cloud after registerGenericQuotaFetchers()" + ); +}); + test("getUsageForProvider returns helpful message when Ollama Cloud has no usage cookie", async () => { const originalCookie = process.env.OLLAMA_USAGE_COOKIE; const originalOmniCookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; diff --git a/tests/unit/openai-to-claude-strip-empty-signature-6953.test.ts b/tests/unit/openai-to-claude-strip-empty-signature-6953.test.ts new file mode 100644 index 0000000000..f129a64f70 --- /dev/null +++ b/tests/unit/openai-to-claude-strip-empty-signature-6953.test.ts @@ -0,0 +1,202 @@ +/** + * TDD regression for #6953 — thinking blocks with empty signatures poison the + * Anthropic leg of combo/blend routes. + * + * Non-Anthropic providers (codex/gpt-5.x) synthesize Anthropic-format `thinking` + * blocks with `signature: ""`. When the client replays these in the next + * request's history, the Anthropic leg rejects them with HTTP 400 "Invalid + * signature in thinking block", and the router silently falls back to codex + * permanently. + * + * The old code fabricated a DEFAULT_THINKING_CLAUDE_SIGNATURE to fill the empty + * signature — but that fabricated signature is equally foreign to Anthropic, so + * it also 400'd. + * + * Fix (#6953): strip thinking blocks with empty/missing signatures entirely. + * They carry no replayable cryptographic value. For `redacted_thinking`, strip + * if `data` is empty/missing for the same reason. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToClaudeRequest } = + await import("../../open-sse/translator/request/openai-to-claude.ts"); + +test('#6953: thinking block with signature:"" is stripped, not fabricated', () => { + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "text", text: "I will help you." }, + { type: "thinking", thinking: "reasoning here", signature: "" }, + { + type: "text", + text: "Let me use a tool.", + }, + ], + }, + { role: "user", content: "ok go ahead" }, + ], + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant, "expected assistant message"); + + // The thinking block with empty signature must be DROPPED, not preserved + // with a fabricated signature. + const thinkingBlocks = assistant.content.filter((b) => b && b.type === "thinking"); + assert.equal( + thinkingBlocks.length, + 0, + "thinking block with empty signature must be stripped, not fabricated" + ); + + // Text blocks must survive + const textBlocks = assistant.content.filter((b) => b && b.type === "text"); + assert.ok(textBlocks.length >= 1, "text blocks must be preserved"); +}); + +test("#6953: thinking block with valid signature is preserved verbatim", () => { + const realSig = "EuY2xhdWRlLXNpZ25hdHVyZS0xNzA5..."; + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "real reasoning", signature: realSig }, + { type: "text", text: "response" }, + ], + }, + { role: "user", content: "ok" }, + ], + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant); + + const thinkingBlocks = assistant.content.filter((b) => b && b.type === "thinking"); + assert.equal(thinkingBlocks.length, 1, "valid thinking block must be preserved"); + assert.equal(thinkingBlocks[0].signature, realSig, "valid signature must be preserved verbatim"); +}); + +test("#6953: thinking block with undefined signature (Claude-format) is preserved with fallback", () => { + // Claude-format messages may have thinking blocks without a signature field at all. + // These are legitimate and must NOT be stripped — only signature:"" (empty string) + // indicates a non-Anthropic synthesized block. + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "I already have this" }, + { type: "text", text: "response" }, + ], + }, + { role: "user", content: "ok" }, + ], + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant); + + const thinkingBlocks = assistant.content.filter((b) => b && b.type === "thinking"); + assert.equal( + thinkingBlocks.length, + 1, + "thinking block with undefined signature must be preserved" + ); + assert.equal(thinkingBlocks[0].thinking, "I already have this", "thinking content must match"); + assert.ok(thinkingBlocks[0].signature, "fallback signature must be applied"); +}); + +test("#6953: redacted_thinking with empty data is stripped", () => { + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "redacted_thinking", data: "" }, + { type: "text", text: "response" }, + ], + }, + { role: "user", content: "ok" }, + ], + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant); + + const redactedBlocks = assistant.content.filter((b) => b && b.type === "redacted_thinking"); + assert.equal(redactedBlocks.length, 0, "redacted_thinking with empty data must be stripped"); +}); + +test("#6953: combo scenario — codex-sourced thinking block does not block Anthropic leg", () => { + // Simulates a combo route: turn 1 served by codex produced a thinking block + // with signature:"". Turn 2 should be able to route to Anthropic without + // the poisoned block causing a 400. + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + messages: [ + { role: "user", content: "write a function" }, + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "**Reviewing the request**\n\nI need to write a function...", + signature: "", // codex-sourced, no real signature + }, + { type: "text", text: "Here's the function:" }, + { + type: "tool_use", + id: "toolu_01abc", + name: "write_file", + input: { path: "main.rs", content: "fn main() {}" }, + }, + ], + }, + { role: "user", content: "looks good, now add tests" }, + ], + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant); + + // No thinking block with empty-string signature should survive + const badThinking = assistant.content.find( + (b) => b && b.type === "thinking" && b.signature === "" + ); + assert.equal( + badThinking, + undefined, + "no thinking block with empty-string signature should survive" + ); + + // Tool use must survive + const toolUse = assistant.content.find((b) => b && b.type === "tool_use"); + assert.ok(toolUse, "tool_use block must be preserved"); +}); diff --git a/tests/unit/opencode-free-tier-catalog-stale-6998.test.ts b/tests/unit/opencode-free-tier-catalog-stale-6998.test.ts new file mode 100644 index 0000000000..c1d3eeae09 --- /dev/null +++ b/tests/unit/opencode-free-tier-catalog-stale-6998.test.ts @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { opencodeProvider } = await import( + "../../open-sse/config/providers/registry/opencode/index.ts" +); + +function modelIds(): string[] { + return (opencodeProvider.models ?? []).map((m) => m.id); +} + +const DELISTED_FREE_MODELS = [ + "minimax-m3-free", + "minimax-m2.5-free", + "ling-2.6-1t-free", + "trinity-large-preview-free", + "nemotron-3-super-free", + "qwen3.6-plus-free", +]; + +const LIVE_FREE_MODELS_MISSING_FROM_CATALOG = [ + "mimo-v2.5-free", + "hy3-free", + "nemotron-3-ultra-free", + "north-mini-code-free", +]; + +test("issue #6998: oc registry does not advertise delisted free-tier models", () => { + const ids = modelIds(); + for (const delisted of DELISTED_FREE_MODELS) { + assert.ok(!ids.includes(delisted), `oc registry still advertises delisted upstream model "${delisted}"`); + } +}); + +test("issue #6998: oc registry advertises the current live free-tier models", () => { + const ids = modelIds(); + for (const live of LIVE_FREE_MODELS_MISSING_FROM_CATALOG) { + assert.ok(ids.includes(live), `oc registry is missing live upstream free-tier model "${live}"`); + } +}); diff --git a/tests/unit/opencode-go-quota-no-zai.test.ts b/tests/unit/opencode-go-quota-no-zai.test.ts new file mode 100644 index 0000000000..b4775040fa --- /dev/null +++ b/tests/unit/opencode-go-quota-no-zai.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { getOpenCodeGoUsage } from "../../open-sse/services/opencodeOllamaUsage.ts"; + +test("getOpenCodeGoUsage does not send the user's OpenCode Go API key to api.z.ai by default", async () => { + const originalFetch = globalThis.fetch; + const originalEnv = process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; + delete process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; + + let calledHost: string | null = null; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + calledHost = new URL(url).host; + throw new Error(`unexpected outbound fetch to ${url}`); + }) as typeof fetch; + + try { + const result = await getOpenCodeGoUsage("sk-fake-opencode-go-key", undefined); + assert.notStrictEqual(calledHost, "api.z.ai"); + assert.strictEqual(calledHost, null); + assert.ok( + typeof result.message === "string" && result.message.length > 0, + "expected a descriptive message when no quota URL is configured" + ); + } finally { + globalThis.fetch = originalFetch; + if (originalEnv === undefined) delete process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; + else process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL = originalEnv; + } +}); diff --git a/tests/unit/opencode-go-usage.test.ts b/tests/unit/opencode-go-usage.test.ts index 64e6357800..302a95f244 100644 --- a/tests/unit/opencode-go-usage.test.ts +++ b/tests/unit/opencode-go-usage.test.ts @@ -1,9 +1,25 @@ -import test from "node:test"; +import test, { after } from "node:test"; import assert from "node:assert/strict"; +// The OpenCode Go quota-by-API-key path is opt-in only (see #7022 — there is no +// working default quota endpoint, so OMNIROUTE_OPENCODE_GO_QUOTA_URL must be set +// explicitly by the operator). The module reads this env var once at import time, +// so it has to be set BEFORE the dynamic import below for the opt-in tests in this +// file (which simulate an operator who configured the URL) to exercise the fetch path. +const ORIGINAL_OPENCODE_GO_QUOTA_URL = process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; +process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit"; + const usage = await import("../../open-sse/services/usage.ts"); const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); +after(() => { + if (ORIGINAL_OPENCODE_GO_QUOTA_URL === undefined) { + delete process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; + } else { + process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL = ORIGINAL_OPENCODE_GO_QUOTA_URL; + } +}); + test("USAGE_SUPPORTED_PROVIDERS includes opencode-go", () => { assert.ok( (USAGE_SUPPORTED_PROVIDERS as string[]).includes("opencode-go"), @@ -298,7 +314,8 @@ test("getUsageForProvider returns message for invalid OpenCode Go API keys", asy })) as { message: string }; assert.equal( result.message, - "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + + "OpenCode Go API key is valid for chat/models but cannot read quota from the configured " + + "OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping." ); } finally { @@ -342,7 +359,8 @@ test("getUsageForProvider returns message when OpenCode Go quota API returns 200 })) as { message: string }; assert.equal( result.message, - "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + + "OpenCode Go API key is valid for chat/models but cannot read quota from the configured " + + "OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping." ); } finally { diff --git a/tests/unit/pack-artifact-entrypoint-closures.test.ts b/tests/unit/pack-artifact-entrypoint-closures.test.ts new file mode 100644 index 0000000000..94a037a68c --- /dev/null +++ b/tests/unit/pack-artifact-entrypoint-closures.test.ts @@ -0,0 +1,152 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + APP_STAGING_ALLOWED_EXACT_PATHS, + PACK_ARTIFACT_REQUIRED_PATHS, +} from "../../scripts/build/pack-artifact-policy.ts"; + +// Generalization of pack-artifact-server-ws-closure.test.ts (#7065 class, 3rd recurrence: +// tls-options/3.8.41, head-response-guard VPS #7040 + npm #7065). assembleStandalone copies +// wrapper modules to the dist ROOT; the prepublish prune then deletes anything not in +// APP_STAGING_ALLOWED_EXACT_PATHS, and check:pack-artifact only fails for entries in +// PACK_ARTIFACT_REQUIRED_PATHS. The original test hardcoded ONE wrapper (server-ws.mjs) +// and ONE import form (static `from "./x"`). This suite derives the full closure from the +// sources of truth — EXTRA_MODULE_ENTRIES in assembleStandalone.mjs plus each wrapper's own +// imports (static, dynamic import() and require()) — so adding an import to ANY npm-shipped +// wrapper without updating both lists fails here instead of shipping a boot-crashing tarball. + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const ASSEMBLE = path.join(ROOT, "scripts", "build", "assembleStandalone.mjs"); +const BIN_ENTRY = path.join(ROOT, "bin", "omniroute.mjs"); + +interface WrapperEntry { + src: string; + dest: string; +} + +// EXTRA_MODULE_ENTRIES entries whose dest is a bare module filename at the dist root — +// these are the boot-path wrappers the prune can silently drop (the #7065 shape). +function distRootWrappers(): WrapperEntry[] { + const text = fs.readFileSync(ASSEMBLE, "utf8"); + const entries = [...text.matchAll(/src:\s*\[([^\]]+)\],?\s*dest:\s*\[([^\]]+)\]/gs)]; + const toPath = (segmentList: string) => + segmentList + .split(",") + .map((s) => s.trim().replace(/^"|"$/g, "")) + .filter(Boolean) + .join("/"); + return entries + .map((m) => ({ src: toPath(m[1]), dest: toPath(m[2]) })) + .filter((e) => !e.dest.includes("/") && /\.(mjs|cjs|js)$/.test(e.dest)); +} + +// Local sibling imports of a module: static `from "./x"`, dynamic `import("./x")`, +// and CommonJS `require("./x")`. The original test missed the dynamic form — server-ws +// boots dist/server.js via `await import("./server.js")`. +function localImports(filePath: string): string[] { + const src = fs.readFileSync(filePath, "utf8"); + const patterns = [ + /from\s+["']\.\/([^"']+)["']/g, + /import\(\s*["']\.\/([^"']+)["']\s*\)/g, + /require\(\s*["']\.\/([^"']+)["']\s*\)/g, + ]; + return [...new Set(patterns.flatMap((re) => [...src.matchAll(re)].map((m) => m[1])))]; +} + +/** Parent-relative specifiers (../) in a wrapper file — ALWAYS a packaging bug. */ +export function parentRelativeImports(src: string): string[] { + const patterns = [ + /from\s+["'](\.\.\/[^"']+)["']/g, + /import\(\s*["'](\.\.\/[^"']+)["']\s*\)/g, + /require\(\s*["'](\.\.\/[^"']+)["']\s*\)/g, + ]; + return [...new Set(patterns.flatMap((re) => [...src.matchAll(re)].map((m) => m[1])))]; +} + +// Wrappers that ship in the npm channel are exactly those whose dest survives the prune. +// Wrappers intentionally outside the npm tarball (e.g. healthcheck.mjs, Docker-only) are +// excluded: their imports live or die with them, consistently. +function npmShippedWrappers(): WrapperEntry[] { + return distRootWrappers().filter((e) => APP_STAGING_ALLOWED_EXACT_PATHS.includes(e.dest)); +} + +test("sanity: EXTRA_MODULE_ENTRIES parsing finds the known dist-root wrappers", () => { + const dests = distRootWrappers().map((e) => e.dest); + for (const known of ["server-ws.mjs", "peer-stamp.mjs", "head-response-guard.cjs"]) { + assert.ok(dests.includes(known), `parser lost known wrapper ${known}: got ${dests.join(", ")}`); + } + assert.ok(dests.length >= 7, `parsed only ${dests.length} dist-root wrappers`); +}); + +test("every local import of every npm-shipped wrapper survives the prune (allowlist)", () => { + for (const wrapper of npmShippedWrappers()) { + const srcPath = path.join(ROOT, wrapper.src); + assert.ok(fs.existsSync(srcPath), `EXTRA_MODULE_ENTRIES src missing on disk: ${wrapper.src}`); + const missing = localImports(srcPath).filter( + (f) => !APP_STAGING_ALLOWED_EXACT_PATHS.includes(f) + ); + assert.deepEqual( + missing, + [], + `${wrapper.dest}: add to APP_STAGING_ALLOWED_EXACT_PATHS: ${missing.join(", ")}` + ); + } +}); + +test("every local import of every npm-shipped wrapper is enforced by check:pack-artifact", () => { + for (const wrapper of npmShippedWrappers()) { + const missing = localImports(path.join(ROOT, wrapper.src)).filter( + (f) => !PACK_ARTIFACT_REQUIRED_PATHS.includes(`dist/${f}`) + ); + assert.deepEqual( + missing, + [], + `${wrapper.dest}: add dist/ to PACK_ARTIFACT_REQUIRED_PATHS: ${missing.join(", ")}` + ); + } +}); + +test("dynamic import() closure is covered (server-ws boots dist/server.js)", () => { + const serverWs = distRootWrappers().find((e) => e.dest === "server-ws.mjs"); + assert.ok(serverWs, "server-ws.mjs wrapper not found in EXTRA_MODULE_ENTRIES"); + const imports = localImports(path.join(ROOT, serverWs.src)); + assert.ok( + imports.includes("server.js"), + `dynamic import extraction broken — server.js not among: ${imports.join(", ")}` + ); +}); + +test("every bin/omniroute.mjs local import is enforced by check:pack-artifact", () => { + // The CLI boot path (bin/omniroute.mjs → bin/cli/*) is covered by allowlist PREFIXES, + // so a file vanishing from the tarball never fails the unexpected-paths check — only + // PACK_ARTIFACT_REQUIRED_PATHS makes its absence loud. Derive the requirement from + // the entrypoint's own imports. + const missing = localImports(BIN_ENTRY).filter( + (f) => !PACK_ARTIFACT_REQUIRED_PATHS.includes(`bin/${f}`) + ); + assert.deepEqual( + missing, + [], + `add bin/ to PACK_ARTIFACT_REQUIRED_PATHS: ${missing.join(", ")}` + ); +}); + +test("no npm-shipped wrapper uses a parent-relative (../) import — it escapes the package after the dist-root copy", () => { + // 2026-07-15 live incident: standalone-server-ws.mjs imported + // ../../src/shared/utils/runtimeTimeouts.ts (merged in #7191); copied to the dist + // root, the specifier resolved to node_modules/src/... OUTSIDE the package and + // every boot of the packed tarball crashed with ERR_MODULE_NOT_FOUND (#7065 + // class — caught by check:pack-boot). Wrapper dependencies must be SIBLINGS + // (./x.mjs) with their own EXTRA_MODULE_ENTRIES copy + pack allowlist entry. + for (const wrapper of npmShippedWrappers()) { + const escaping = parentRelativeImports(fs.readFileSync(path.join(ROOT, wrapper.src), "utf8")); + assert.deepEqual( + escaping, + [], + `${wrapper.src} has package-escaping imports: ${escaping.join(", ")} — extract to a sibling module instead` + ); + } +}); diff --git a/tests/unit/pack-artifact-policy.test.ts b/tests/unit/pack-artifact-policy.test.ts index a5f78e2235..9738342e7e 100644 --- a/tests/unit/pack-artifact-policy.test.ts +++ b/tests/unit/pack-artifact-policy.test.ts @@ -105,11 +105,14 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball", // alphabetically (bin/ < dist/ < scripts/ < src/), minus the paths present // above (dist/server.js, bin/omniroute.mjs, package.json, the postinstall scripts). assert.deepEqual(missingPaths, [ + "bin/cli/data-dir.mjs", "bin/cli/program.mjs", + "bin/cli/utils/storageKeyProvision.mjs", "bin/mcp-server.mjs", "bin/nodeRuntimeSupport.mjs", "dist/head-response-guard.cjs", "dist/http-method-guard.cjs", + "dist/main-server-timeouts.mjs", "dist/open-sse/services/compression/engines/rtk/filters/generic-output.json", "dist/open-sse/services/compression/rules/en/filler.json", "dist/peer-stamp.mjs", diff --git a/tests/unit/probe-6699-jules-executor-misroute.test.ts b/tests/unit/probe-6699-jules-executor-misroute.test.ts new file mode 100644 index 0000000000..a2072814f4 --- /dev/null +++ b/tests/unit/probe-6699-jules-executor-misroute.test.ts @@ -0,0 +1,40 @@ +// Probe for issue #6699 -- "Google Jules provider validation rejects a valid API key". +// +// A second reporter (MohammadMD1383) supplied screenshots showing that OmniRoute, when +// actually routing a chat-completion request for a saved "jules" connection, sends the +// request to https://api.openai.com/v1/chat/completions and surfaces OpenAI's own +// "Incorrect API key provided ... platform.openai.com" error -- even though the provider +// is displayed as JULES with target "jules/jules". This probe proves the executor-level +// root cause directly: getExecutor("jules") has no specialized executor and no REGISTRY +// entry, so DefaultExecutor's constructor silently falls back to PROVIDERS.openai, +// making buildUrl() return OpenAI's endpoint for a provider the user believes is Jules. +import test from "node:test"; +import assert from "node:assert/strict"; +import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts"; + +test("#6699: jules has no specialized executor (falls through to DefaultExecutor)", () => { + assert.equal(hasSpecializedExecutor("jules"), false); +}); + +test("#6699: a chat-completion request routed to provider 'jules' must not silently hit OpenAI's endpoint", () => { + // Desired behavior: the Jules provider (a cloud-agent, registered only in + // CLOUD_AGENT_PROVIDERS/staticModels, never in the chat REGISTRY) must not silently + // resolve to OpenAI's chat/completions endpoint when routed through the normal + // chat-completions executor path. getExecutor() now throws a clear, sanitized error + // for this narrow set of chat-unsupported cloud-agent providers instead of falling + // through to DefaultExecutor's `PROVIDERS.openai` fallback (which produced the + // "Incorrect API key provided ... platform.openai.com" error the reporter saw for a + // genuine Jules key). Before the fix, getExecutor("jules") returned a working + // executor whose buildUrl() resolved to OpenAI's endpoint -- this assertion FAILS on + // unfixed release/v3.8.49 code because no error is thrown at all. + assert.throws( + () => getExecutor("jules"), + (err) => { + assert.match(err.message, /cloud-agent provider/i); + assert.match(err.message, /does not support direct chat completions/i); + assert.equal(err.status, 400); + return true; + }, + "provider 'jules' must raise a clear error instead of silently inheriting OpenAI's base URL/config" + ); +}); diff --git a/tests/unit/probe-6835-cyclebreaker.test.ts b/tests/unit/probe-6835-cyclebreaker.test.ts new file mode 100644 index 0000000000..fda653124d --- /dev/null +++ b/tests/unit/probe-6835-cyclebreaker.test.ts @@ -0,0 +1,28 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +test("getDbInstance() caps the probe-failed/restore cycle at 3 attempts (#6835)", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-6835-")); + process.env.DATA_DIR = tmpDir; + const sqliteFile = path.join(tmpDir, "storage.sqlite"); + const backupFile = `${sqliteFile}.probe-failed-1000000000000`; + fs.writeFileSync(backupFile, Buffer.from("not a real sqlite file, always fails to open")); + const core = await import("../../src/lib/db/core.ts"); + const errors: string[] = []; + for (let i = 0; i < 6; i++) { + try { + core.getDbInstance(); + errors.push("(no error)"); + break; + } catch (err: unknown) { + errors.push(err instanceof Error ? err.message : String(err)); + } + } + const abortIndex = errors.findIndex((e) => e.includes("Aborting startup")); + assert.notEqual(abortIndex, -1, "Expected the cap to trip; got: " + errors.join(" | ")); + assert.ok(abortIndex <= 4, "Expected cap by call #4; took until #" + abortIndex); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); diff --git a/tests/unit/probe-6835-oom-uncapped.test.ts b/tests/unit/probe-6835-oom-uncapped.test.ts new file mode 100644 index 0000000000..9b91101646 --- /dev/null +++ b/tests/unit/probe-6835-oom-uncapped.test.ts @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +test("getDbInstance() eventually caps a persistently-OOMing sql.js probe (#6835)", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-6835-oom-")); + process.env.DATA_DIR = tmpDir; + const sqliteFile = path.join(tmpDir, "storage.sqlite"); + fs.mkdirSync(sqliteFile); // forces better-sqlite3/node:sqlite to fail synchronously (EISDIR-style) + await import("../../src/lib/db/adapters/driverFactory.ts"); + const core = await import("../../src/lib/db/core.ts"); + const fakeAdapter = { + driver: "sql.js" as const, + open: true, + name: sqliteFile, + prepare() { + throw new Error("out of memory"); + }, + exec() { + throw new Error("out of memory"); + }, + pragma() { + throw new Error("out of memory"); + }, + transaction(fn: (...a: unknown[]) => T) { + return fn; + }, + immediate() {}, + async backup() {}, + checkpoint() {}, + close() {}, + raw: null, + }; + ( + globalThis as unknown as { __omnirouteSqlJsAdapters: Map } + ).__omnirouteSqlJsAdapters = new Map([[sqliteFile, fakeAdapter]]); + const errors: string[] = []; + for (let i = 0; i < 8; i++) { + try { + core.getDbInstance(); + errors.push("(no error)"); + break; + } catch (err: unknown) { + errors.push(err instanceof Error ? err.message : String(err)); + } + } + const anyAborted = errors.some((e) => e.includes("Aborting startup")); + assert.ok( + anyAborted, + "Expected getDbInstance() to eventually give up with a terminal " + + "'Aborting startup'-style diagnostic after repeated OOM probe failures, the same way it " + + "already does for generic corruption (#6632). Instead every call re-threw an identical, " + + "uncapped OOM error:\n" + errors.map((e, i) => ` [${i}] ${e}`).join("\n") + ); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); diff --git a/tests/unit/probe-7134-claude-web-empty-error-body.test.ts b/tests/unit/probe-7134-claude-web-empty-error-body.test.ts new file mode 100644 index 0000000000..b62659616a --- /dev/null +++ b/tests/unit/probe-7134-claude-web-empty-error-body.test.ts @@ -0,0 +1,102 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { writeFile } from "node:fs/promises"; + +// Issue #7134 — claude-web reported "Claude Web API error (400) with no +// response body" even when Claude's upstream DID send a real JSON error body. +// +// Root cause: tlsFetchStreaming() streams the upstream response to a temp +// file via tls-client-node's `streamOutputPath` mode. For a non-SSE, +// non-2xx response, the native binding resolves with an EMPTY in-memory +// `body` field (it only populates `body` for its non-streaming mode) even +// though the real error bytes were already written to the temp file and +// even peeked (`looksLikeSse`) to decide the response wasn't SSE. The old +// code read the empty `r.body` instead of the file it just peeked, throwing +// away the real upstream error detail. +// +// This test injects a fake `client` (matching the `{ request }` shape +// tlsFetchStreaming already accepts for DI) that reproduces the exact +// tls-client-node contract under `streamOutputPath`: write bytes to the file, +// resolve with an empty `body`. No `--experimental-test-module-mocks` flag +// needed — this exercises the real, unmodified `tlsFetchStreaming` via +// dependency injection instead of module-mocking `tls-client-node`. + +const { tlsFetchStreaming } = await import("../../open-sse/services/claudeTlsClient.ts"); + +const REAL_CLAUDE_ERROR_BODY = JSON.stringify({ + type: "error", + error: { + type: "invalid_request_error", + message: "This conversation UUID does not exist or you do not have access to it.", + }, +}); + +function makeFakeClient(status: number, bodyOnFile: string) { + return { + request: async (_url: string, opts: Record) => { + const streamOutputPath = opts.streamOutputPath as string; + await writeFile(streamOutputPath, bodyOnFile); + return { + status, + headers: {}, + // tls-client-node does not populate `body` for streamed requests — + // this is the exact defect condition. + body: "", + cookies: {}, + text: async () => "", + json: async () => ({}), + bytes: async () => new Uint8Array(), + }; + }, + }; +} + +test("issue #7134: tlsFetchStreaming surfaces the real error body for a non-SSE 400 under stream:true", async () => { + const client = makeFakeClient(400, REAL_CLAUDE_ERROR_BODY); + + const result = await tlsFetchStreaming( + client, + "https://claude.ai/api/organizations/x/chat_conversations/y/completion", + { method: "POST" }, + "[DONE]", + null, + 5_000 + ); + + assert.equal(result.status, 400); + assert.equal(result.body, null); + assert.ok( + result.text && result.text.includes("does not exist or you do not have access to it"), + `expected the real Claude error body to be surfaced, got: ${JSON.stringify(result.text)}` + ); +}); + +test("issue #7134: tlsFetchStreaming still uses r.body when the native client DOES populate it", async () => { + const client = { + request: async (_url: string, opts: Record) => { + const streamOutputPath = opts.streamOutputPath as string; + await writeFile(streamOutputPath, "{}"); + return { + status: 403, + headers: {}, + body: "populated body from native client", + cookies: {}, + text: async () => "", + json: async () => ({}), + bytes: async () => new Uint8Array(), + }; + }, + }; + + const result = await tlsFetchStreaming( + client, + "https://claude.ai/api/organizations/x/chat_conversations/y/completion", + { method: "POST" }, + "[DONE]", + null, + 5_000 + ); + + assert.equal(result.status, 403); + assert.equal(result.text, "populated body from native client"); +}); diff --git a/tests/unit/provider-models-vision-override-1904.test.ts b/tests/unit/provider-models-vision-override-1904.test.ts new file mode 100644 index 0000000000..508c6a1399 --- /dev/null +++ b/tests/unit/provider-models-vision-override-1904.test.ts @@ -0,0 +1,151 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// #1904: manual vision-capability override for custom OpenAI-compatible models. +// +// detectVisionInput()/getCustomVisionCapabilityFields() already honour an explicit +// `supportsVision` flag when it is present on a custom-model record, but there was no +// way to *set* that flag from the dashboard's "Custom Models" add/edit form and no +// persistence path in the POST/PUT /api/provider-models handlers — so a user whose +// self-hosted backend doesn't self-report an image input modality (e.g. OpenRouter-style +// `architecture.input_modalities`) had no way to manually flag the model as +// vision-capable, exactly the report in the linked issue (Qwen-based custom vision +// model not showing the vision tag). +// +// This test proves the API round trip end-to-end: POST/PUT persist supportsVision on +// the custom-model row, GET surfaces it back, and getCustomVisionCapabilityFields() +// (what the /v1/models catalog calls) honours the explicit override. + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-provider-model-vision-override-1904-") +); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const providerModelsRoute = await import("../../src/app/api/provider-models/route.ts"); +const catalogVision = await import("../../src/app/api/v1/models/catalogVision.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function buildRequest(method: string, body: unknown) { + return new Request("http://localhost/api/provider-models", { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test("POST with supportsVision:true persists the flag on the custom model row", async () => { + const postRes = await providerModelsRoute.POST( + buildRequest("POST", { + provider: "openai-compatible-demo", + modelId: "qwen-vl-custom", + modelName: "Qwen VL Custom", + apiFormat: "chat-completions", + supportedEndpoints: ["chat"], + supportsVision: true, + }) + ); + const postBody = (await postRes.json()) as { model?: { supportsVision?: boolean } }; + assert.equal(postRes.status, 200); + assert.equal(postBody.model?.supportsVision, true); + + const models = await modelsDb.getCustomModels("openai-compatible-demo"); + const row = (models as Array<{ id?: string; supportsVision?: boolean }>).find( + (m) => m.id === "qwen-vl-custom" + ); + assert.ok(row, "model row should exist"); + assert.equal(row!.supportsVision, true); +}); + +test("PUT with supportsVision:true persists a manual override and PUT null clears it", async () => { + await modelsDb.addCustomModel( + "openai-compatible-demo", + "custom-local-model", + "Custom Local Model" + ); + + const putRes = await providerModelsRoute.PUT( + buildRequest("PUT", { + provider: "openai-compatible-demo", + modelId: "custom-local-model", + supportsVision: true, + }) + ); + const putBody = (await putRes.json()) as { model?: { supportsVision?: boolean } }; + assert.equal(putRes.status, 200); + assert.equal(putBody.model?.supportsVision, true); + + const getRes = await providerModelsRoute.GET( + new Request("http://localhost/api/provider-models?provider=openai-compatible-demo") + ); + const getBody = (await getRes.json()) as { + models: Array<{ id?: string; supportsVision?: boolean }>; + }; + const row = getBody.models.find((m) => m.id === "custom-local-model"); + assert.ok(row, "model row should be present"); + assert.equal(row!.supportsVision, true); + + // Clearing back to the id-based heuristic. + const clearRes = await providerModelsRoute.PUT( + buildRequest("PUT", { + provider: "openai-compatible-demo", + modelId: "custom-local-model", + supportsVision: null, + }) + ); + const clearBody = (await clearRes.json()) as { model?: { supportsVision?: boolean } }; + assert.equal(clearRes.status, 200); + assert.equal(clearBody.model?.supportsVision, undefined); +}); + +test("getCustomVisionCapabilityFields honours an explicit supportsVision:true override", () => { + const fields = catalogVision.getCustomVisionCapabilityFields( + { supportsVision: true }, + "openai-compatible-demo/qwen-not-heuristic-matched" + ); + assert.ok(fields, "explicit override should produce vision capability fields"); + assert.deepEqual(fields!.capabilities, { vision: true }); +}); + +test("getCustomVisionCapabilityFields honours an explicit supportsVision:false override even for a vision-like id", () => { + const fields = catalogVision.getCustomVisionCapabilityFields( + { supportsVision: false }, + "openai-compatible-demo/gpt-4-vision-preview" + ); + assert.equal(fields, null); +}); + +test("without an explicit flag, the UI has no field wired to persist supportsVision by default", async () => { + // Before this fix there was no request-shape carrying supportsVision at all; a plain + // add-model POST (matching the pre-fix form payload) must not silently mark a model + // vision-capable — the flag stays absent unless the user explicitly opts in. + const postRes = await providerModelsRoute.POST( + buildRequest("POST", { + provider: "openai-compatible-demo", + modelId: "plain-model", + apiFormat: "chat-completions", + supportedEndpoints: ["chat"], + }) + ); + const postBody = (await postRes.json()) as { model?: { supportsVision?: boolean } }; + assert.equal(postRes.status, 200); + assert.equal(postBody.model?.supportsVision, undefined); +}); diff --git a/tests/unit/provider-registry-qwen-vision.test.ts b/tests/unit/provider-registry-qwen-vision.test.ts index 9382027aed..1b2ddaa3f2 100644 --- a/tests/unit/provider-registry-qwen-vision.test.ts +++ b/tests/unit/provider-registry-qwen-vision.test.ts @@ -63,16 +63,17 @@ test("#2822 opencode-go/qwen3.6-plus deve ter supportsVision !== true", () => { ); }); -// #3328 — o oposto do #2822: MiniMax M3 (opencode) É multimodal (verificado -// empiricamente: descreve imagens base64 via o upstream opencode). Deve ter -// supportsVision: true para não ser barrado/strippado em requests com imagem. -test("#3328 opencode/minimax-m3-free deve ter supportsVision: true", () => { +// #3328 — o oposto do #2822: MiniMax M3 (opencode) era multimodal (verificado +// empiricamente: descrevia imagens base64 via o upstream opencode). #6998: +// minimax-m3-free foi deslistado do free tier da OpenCode Zen (401 "not +// supported") em 2026-07-14 e removido do catálogo estático — este teste +// agora confirma a remoção. +test("#6998 opencode/minimax-m3-free não deve mais estar registrado (deslistado upstream)", () => { const model = getModel("opencode", "minimax-m3-free"); - assert.ok(model, "minimax-m3-free deve estar registrado em opencode"); - assert.strictEqual( - model.supportsVision, - true, - "opencode/minimax-m3-free é multimodal — supportsVision deve ser true" + assert.equal( + model, + undefined, + "opencode/minimax-m3-free foi deslistado do free tier da OpenCode Zen (#6998)" ); }); diff --git a/tests/unit/provider-scoped-models-route.test.ts b/tests/unit/provider-scoped-models-route.test.ts index 55b40205cb..30662b0d19 100644 --- a/tests/unit/provider-scoped-models-route.test.ts +++ b/tests/unit/provider-scoped-models-route.test.ts @@ -10,16 +10,37 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const combosDb = await import("../../src/lib/db/combos.ts"); +const serviceModelsDb = await import("../../src/lib/db/serviceModels.ts"); const providerModelsRoute = await import("../../src/app/api/v1/providers/[provider]/models/route.ts"); +interface SeedConnectionOverrides { + authType?: string; + name?: string; + apiKey?: string | null; + accessToken?: string | null; + isActive?: boolean; + testStatus?: string; + providerSpecificData?: Record; +} + +type ProviderModelsResponse = { + data: Array>; + object?: string; + error?: { + message?: string; + code?: string; + type?: string; + }; +}; + async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); } -async function seedConnection(provider: string, overrides: Record = {}) { +async function seedConnection(provider: string, overrides: SeedConnectionOverrides = {}) { return providersDb.createProviderConnection({ provider, authType: overrides.authType || "apikey", @@ -62,8 +83,8 @@ test("provider models route returns only selected provider models with unprefixe } ); - const body = (await response.json()) as any; - const ids = body.data.map((model: any) => model.id); + const body = (await response.json()) as ProviderModelsResponse; + const ids = body.data.map((model) => String(model.id)); assert.equal(response.status, 200); assert.ok(ids.length > 0); @@ -72,7 +93,7 @@ test("provider models route returns only selected provider models with unprefixe false ); assert.equal( - body.data.some((model: any) => model.owned_by !== "openai"), + body.data.some((model) => String(model.owned_by) !== "openai"), false ); assert.equal(ids.includes("team-router"), false); @@ -93,8 +114,8 @@ test("provider models route accepts provider alias in path", async () => { } ); - const body = (await response.json()) as any; - const ids = body.data.map((model: any) => model.id); + const body = (await response.json()) as ProviderModelsResponse; + const ids = body.data.map((model) => String(model.id)); assert.equal(response.status, 200); assert.ok(ids.includes("claude-sonnet-4-6")); @@ -104,6 +125,42 @@ test("provider models route accepts provider alias in path", async () => { ); }); +test("provider models route supports service provider 9router", async () => { + serviceModelsDb.saveServiceModels("9router", [{ id: "gpt-4o-mini", name: "Local9R Test", available: true }]); + + const response = await providerModelsRoute.GET( + new Request("http://localhost/api/v1/providers/9router/models"), + { + params: Promise.resolve({ provider: "9router" }), + } + ); + + const body = (await response.json()) as ProviderModelsResponse; + const ids = body.data.map((model) => String(model.id)); + + assert.equal(response.status, 200); + assert.ok(ids.includes("gpt-4o-mini")); + assert.equal(ids.some((id: string) => id.includes("/")), false); +}); + +test("provider models route supports service provider cliproxyapi", async () => { + serviceModelsDb.saveServiceModels("cliproxyapi", [{ id: "llama-3", name: "Clip Test", available: true }]); + + const response = await providerModelsRoute.GET( + new Request("http://localhost/api/v1/providers/cliproxyapi/models"), + { + params: Promise.resolve({ provider: "cliproxyapi" }), + } + ); + + const body = (await response.json()) as ProviderModelsResponse; + const ids = body.data.map((model) => String(model.id)); + + assert.equal(response.status, 200); + assert.ok(ids.includes("llama-3")); + assert.equal(ids.some((id: string) => id.includes("/")), false); +}); + test("provider models route returns 400 for unknown provider", async () => { const response = await providerModelsRoute.GET( new Request("http://localhost/api/v1/providers/nope/models"), @@ -112,7 +169,7 @@ test("provider models route returns 400 for unknown provider", async () => { } ); - const body = (await response.json()) as any; + const body = (await response.json()) as ProviderModelsResponse; assert.equal(response.status, 400); assert.equal(body.error.code, "invalid_provider"); diff --git a/tests/unit/provider-validation-web-cookie-auth007.test.ts b/tests/unit/provider-validation-web-cookie-auth007.test.ts index c53705af04..344b1e865b 100644 --- a/tests/unit/provider-validation-web-cookie-auth007.test.ts +++ b/tests/unit/provider-validation-web-cookie-auth007.test.ts @@ -1,15 +1,18 @@ import test from "node:test"; import assert from "node:assert/strict"; -// The validator probes the provider's /models endpoint via safeOutboundFetch → -// fetchWithTimeout, which binds globalThis.fetch at MODULE LOAD time. The mock MUST be -// installed BEFORE importing validation.ts — a late reassignment (inside a test) is -// ignored and the validator hits the real network instead. (That made the 401/403 -// assertions pass only by coincidence — live chatgpt.com returns 401/403 — while the -// 200 case failed.) A mutable `nextResponse` lets each test vary the probe result, and -// `fetchCalls` proves the mocked probe ran rather than the live network. +// The validator probes the provider's /models endpoint via validationRead → safeOutboundFetch +// → fetchWithTimeout, which reads `globalThis.fetch` dynamically at CALL time (#7058 — routed +// through the proxy-aware patched fetch instead of a bypassing directHttpsRequest). Importing +// validation.ts pulls in the proxy-patch module, which installs its own `globalThis.fetch` +// exactly once at import time — so the mock must be (re)installed AFTER the import, not before, +// or the patch silently clobbers it. A mutable `nextResponse` lets each test vary the probe +// result, and `fetchCalls` proves the mocked probe ran rather than the live network. let nextResponse: { status: number; body: string } = { status: 200, body: "{}" }; let fetchCalls = 0; + +const { validateWebCookieProvider } = await import("../../src/lib/providers/validation.ts"); + globalThis.fetch = (async () => { fetchCalls++; return new Response(nextResponse.body, { @@ -18,8 +21,6 @@ globalThis.fetch = (async () => { }); }) as typeof fetch; -const { validateWebCookieProvider } = await import("../../src/lib/providers/validation.ts"); - function mockFetch(status: number, body: string) { nextResponse = { status, body }; fetchCalls = 0; diff --git a/tests/unit/providers-yuanbao-web.test.ts b/tests/unit/providers-yuanbao-web.test.ts index 2feab63371..48477c83d4 100644 --- a/tests/unit/providers-yuanbao-web.test.ts +++ b/tests/unit/providers-yuanbao-web.test.ts @@ -71,6 +71,13 @@ async function readStreamText(res: Response): Promise { } test("missing hy_token cookie returns a 401 auth error", async () => { + // Hermetic: the 401 must come from the executor's own cookie validation, never + // from the real upstream — on GitHub-hosted runners the Tencent endpoint is + // unreachable and a live call turns this into a 71s 502 false-negative. + const original = globalThis.fetch; + globalThis.fetch = (async () => { + throw new Error("network disabled in this test — executor must reject before fetching"); + }) as typeof fetch; const exec = new YuanbaoWebExecutor(); const { response } = await exec.execute({ model: "deepseek-v3", @@ -84,6 +91,7 @@ test("missing hy_token cookie returns a 401 auth error", async () => { assert.match(body.error.message, /hy_user|hy_token|session cookie/); // Never leak stack traces. assert.ok(!body.error.message.includes("at /")); + globalThis.fetch = original; }); test("streaming request translates think/text events into OpenAI chunks", async () => { diff --git a/tests/unit/quota-card-grid-density-6815.test.ts b/tests/unit/quota-card-grid-density-6815.test.ts new file mode 100644 index 0000000000..bafc3d6b3b --- /dev/null +++ b/tests/unit/quota-card-grid-density-6815.test.ts @@ -0,0 +1,190 @@ +// #6815 — Provider Quota page horizontal density. +// +// #6815 changed QuotaCardGrid.tsx's per-group card grid from a +// single-column-only layout (`flex flex-col`) to one that packs multiple +// QuotaCards side by side on wide screens, instead of stacking every card +// vertically no matter how much horizontal space is available. +// +// That guarantee was only ever asserted *incidentally*, by two other guards +// that pinned the literal Tailwind token the #6815 implementation happened +// to use at the time (`sm:grid-cols-2` in +// tests/unit/quota-card-grid-mobile-7072.test.ts and +// tests/unit/quota-card-grid-horizontal-layout.test.ts). When PR #7027 +// migrated the component from a fixed breakpoint ladder +// (`grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4`) to a +// container-driven auto-fit template +// (`grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))]`), that literal +// token disappeared from the source and both guards were edited to stop +// asserting it — silently deleting the only coverage #6815 had. +// +// This guard re-establishes dedicated coverage for the #6815 density +// guarantee itself, decoupled from *how* the component achieves it. Instead +// of matching a specific class-name token, it simulates, from the shipped +// className(s), how many columns the per-group card grid would actually +// render at a wide container width — supporting both mechanisms seen in this +// component's history (a Tailwind breakpoint ladder, and a CSS auto-fit +// `minmax()` template) — and asserts that count is >1. Reverting to a single +// unconditional column (`grid-cols-1` with no responsive/auto-fit variants, +// or dropping the grid entirely for `flex flex-col`) must fail this guard, +// regardless of which mechanism produced the regression. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +const COMPONENT_PATH = path.resolve( + import.meta.dirname, + "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx" +); + +/** + * Extract the string literal passed to `className={...}` (or `className="..."`) + * for every JSX `
` opening element in the component's source, in source + * order, via the TypeScript compiler API (not a hand-rolled regex — tracks + * the real AST so it can't be fooled by comments/whitespace). + */ +function extractDivClassNames(sourcePath: string): string[] { + const sourceText = fs.readFileSync(sourcePath, "utf8"); + const sourceFile = ts.createSourceFile( + sourcePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + const classNames: string[] = []; + + function visit(node: ts.Node) { + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + const tagName = node.tagName.getText(sourceFile); + if (tagName === "div") { + for (const attr of node.attributes.properties) { + if (ts.isJsxAttribute(attr) && attr.name.getText(sourceFile) === "className") { + const init = attr.initializer; + if (init && ts.isStringLiteral(init)) { + classNames.push(init.text); + } else if ( + init && + ts.isJsxExpression(init) && + init.expression && + ts.isStringLiteral(init.expression) + ) { + classNames.push(init.expression.text); + } + } + } + } + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + return classNames; +} + +// Tailwind's default min-width breakpoints (px). Unprefixed utilities apply +// at every width (breakpoint 0) and later/larger breakpoints win the cascade +// once their min-width is met, mirroring Tailwind's mobile-first source order. +const TAILWIND_BREAKPOINTS: Record = { + sm: 640, + md: 768, + lg: 1024, + xl: 1280, + "2xl": 1536, +}; + +type ColumnRule = + | { breakpoint: number; kind: "fixed"; columns: number } + | { breakpoint: number; kind: "autofit"; trackPx: number }; + +/** + * Parse every `grid-cols-*` utility (optionally breakpoint-prefixed) found in + * a className string into a column rule, supporting both mechanisms this + * component has shipped with: + * - a fixed count, e.g. `grid-cols-2`, `md:grid-cols-3` + * - a CSS auto-fit template, e.g. + * `grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))]`, from which + * the minimum track width (in px) is extracted. + */ +function parseColumnRules(className: string): ColumnRule[] { + const rules: ColumnRule[] = []; + for (const token of className.split(/\s+/).filter(Boolean)) { + const prefixMatch = token.match(/^(?:([a-zA-Z0-9]+):)?grid-cols-(.+)$/); + if (!prefixMatch) continue; + const [, prefix, rest] = prefixMatch; + const breakpoint = prefix ? (TAILWIND_BREAKPOINTS[prefix] ?? 0) : 0; + + if (/^\d+$/.test(rest)) { + rules.push({ breakpoint, kind: "fixed", columns: parseInt(rest, 10) }); + continue; + } + + const autoFitMatch = rest.match(/^\[repeat\(auto-fit,\s*minmax\((.+),\s*1fr\)\)\]$/); + if (autoFitMatch) { + const trackPxMatches = [...autoFitMatch[1].matchAll(/(\d+)px/g)]; + if (trackPxMatches.length > 0) { + const trackPx = parseInt(trackPxMatches[trackPxMatches.length - 1][1], 10); + rules.push({ breakpoint, kind: "autofit", trackPx }); + } + } + } + return rules; +} + +/** + * Given a className string, estimate how many columns the grid renders at a + * given container/viewport width, by picking the widest matching breakpoint + * rule (Tailwind cascade) and resolving fixed vs. auto-fit tracks. Returns 1 + * (single column) when no `grid-cols-*` rule is present at all — e.g. a + * `flex flex-col` layout. + */ +function estimateColumnsAtWidth(className: string, widthPx: number): number { + const rules = parseColumnRules(className).filter((r) => r.breakpoint <= widthPx); + if (rules.length === 0) return 1; + const active = rules.reduce((best, r) => (r.breakpoint >= best.breakpoint ? r : best)); + if (active.kind === "fixed") return active.columns; + return Math.max(1, Math.floor(widthPx / active.trackPx)); +} + +// --- Self-test of the estimator against known-good and known-bad shapes --- +// (independent of the real component, so the simulation logic itself is +// pinned before it's trusted to judge the shipped source below). + +test("#6815 density estimator — breakpoint ladder resolves to multiple columns on a wide viewport", () => { + const className = "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3"; + assert.equal(estimateColumnsAtWidth(className, 1200), 3); + assert.equal(estimateColumnsAtWidth(className, 375), 1); +}); + +test("#6815 density estimator — auto-fit template resolves to multiple columns on a wide container", () => { + const className = "grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3"; + assert.ok(estimateColumnsAtWidth(className, 1200) >= 2); + assert.equal(estimateColumnsAtWidth(className, 200), 1); +}); + +test("#6815 density estimator — single unconditional column stays at 1 column regardless of width", () => { + assert.equal(estimateColumnsAtWidth("grid grid-cols-1 gap-3", 1920), 1); +}); + +test("#6815 density estimator — flex column stack (no grid-cols) resolves to 1 column", () => { + assert.equal(estimateColumnsAtWidth("flex flex-col gap-3", 1920), 1); +}); + +// --- The actual regression guard, reading the shipped component source --- + +test("QuotaCardGrid (#6815) — per-group card grid renders multiple columns on a wide container", () => { + const classNames = extractDivClassNames(COMPONENT_PATH); + const cardGridClassName = classNames.find((c) => /\bgrid\b/.test(c) && /grid-cols-/.test(c)); + assert.ok( + cardGridClassName, + "expected to find a grid-based per-group card grid className (not a single-column flex stack)" + ); + + const columnsOnWideContainer = estimateColumnsAtWidth(cardGridClassName!, 1200); + assert.ok( + columnsOnWideContainer > 1, + `expected the per-group card grid to render more than 1 column at 1200px, got ${columnsOnWideContainer} ` + + `from className="${cardGridClassName}" — this is the #6815 density regression` + ); +}); diff --git a/tests/unit/quota-card-grid-horizontal-layout.test.ts b/tests/unit/quota-card-grid-horizontal-layout.test.ts index 9966034ba9..5e9fcd52ba 100644 --- a/tests/unit/quota-card-grid-horizontal-layout.test.ts +++ b/tests/unit/quota-card-grid-horizontal-layout.test.ts @@ -6,8 +6,11 @@ // the shipped JSX structure and grouping logic directly: // 1. Grouping still produces one header per distinct provider with the // correct account count ("N account(s)"). -// 2. The per-group card grid starts multi-column (`grid-cols-2`), not -// single-column, so cards fill horizontal space sooner. +// 2. The per-group card grid keeps a single-column mobile fallback +// (`grid-cols-1`) below the `sm:` breakpoint (restored by #7072 after +// PR #6815 dropped it and clipped labels on phone-width viewports), +// while still going multi-column (`sm:grid-cols-2`) from `sm:` up so +// cards fill horizontal space sooner on larger screens. // 3. Provider groups themselves flow into multiple columns on wide screens // (`columns-*`) instead of an unconditional vertical `flex flex-col` // stack. @@ -104,14 +107,14 @@ test("QuotaCardGrid (#3520) — outer container flows groups into multiple colum assert.notEqual(outerClassName, "flex flex-col gap-6"); }); -test("QuotaCardGrid (#3520) — per-group card grid starts multi-column (grid-cols-2), not single-column", () => { +test("QuotaCardGrid (#3520/#7072) — per-group card grid keeps a mobile grid-cols-1 fallback and goes multi-column from sm: up", () => { const classNames = extractDivClassNames(COMPONENT_PATH); const cardGridClassName = classNames.find( (c) => /\bgrid\b/.test(c) && /grid-cols-/.test(c) ); assert.ok(cardGridClassName, "expected to find the per-group card grid's className"); - assert.match(cardGridClassName!, /\bgrid-cols-2\b/); - assert.doesNotMatch(cardGridClassName!, /\bgrid-cols-1\b/); + assert.match(cardGridClassName!, /\bgrid-cols-1\b/); + assert.match(cardGridClassName!, /\bsm:grid-cols-2\b/); }); test("QuotaCardGrid (#3520) — early-returns null when there are no connections", () => { diff --git a/tests/unit/quota-card-grid-mobile-7072.test.ts b/tests/unit/quota-card-grid-mobile-7072.test.ts new file mode 100644 index 0000000000..aeede949a8 --- /dev/null +++ b/tests/unit/quota-card-grid-mobile-7072.test.ts @@ -0,0 +1,79 @@ +// #7072 — Provider Quota page card grid clipped on mobile. +// +// PR #6815 changed QuotaCardGrid.tsx's per-group card grid from +// `grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4` to +// `grid-cols-2 md:grid-cols-3 xl:grid-cols-4`, dropping the mobile (<768px) +// single-column fallback that every other card-grid in the dashboard still +// has (ProviderQuotaWidget.tsx, EvalsTab.tsx, MediaPageClient.tsx, +// SystemStorageTab.tsx). Forcing 2 columns even on phone widths squeezes +// each QuotaCard, and since QuotaCard's outer Card uses `overflow-hidden`, +// the overflowing button/label text is clipped instead of wrapping. +// +// This regression guard parses QuotaCardGrid.tsx's JSX via the TypeScript +// compiler API and asserts the per-group card grid's className restores an +// unprefixed `grid-cols-1` mobile fallback, while keeping the #6815 density +// gains (grid-cols-2 at `sm:` and up). + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +const COMPONENT_PATH = path.resolve( + import.meta.dirname, + "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx" +); + +function extractDivClassNames(sourcePath: string): string[] { + const sourceText = fs.readFileSync(sourcePath, "utf8"); + const sourceFile = ts.createSourceFile( + sourcePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + const classNames: string[] = []; + + function visit(node: ts.Node) { + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + const tagName = node.tagName.getText(sourceFile); + if (tagName === "div") { + for (const attr of node.attributes.properties) { + if (ts.isJsxAttribute(attr) && attr.name.getText(sourceFile) === "className") { + const init = attr.initializer; + if (init && ts.isStringLiteral(init)) { + classNames.push(init.text); + } else if ( + init && + ts.isJsxExpression(init) && + init.expression && + ts.isStringLiteral(init.expression) + ) { + classNames.push(init.expression.text); + } + } + } + } + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + return classNames; +} + +test("QuotaCardGrid (#7072) — per-group card grid keeps a single-column mobile fallback", () => { + const classNames = extractDivClassNames(COMPONENT_PATH); + const cardGridClassName = classNames.find((c) => /\bgrid\b/.test(c) && /grid-cols-/.test(c)); + assert.ok(cardGridClassName, "expected to find the per-group card grid's className"); + + const tokens = cardGridClassName!.split(/\s+/); + const unprefixedGridCols = tokens.find((t) => /^grid-cols-\d+$/.test(t)); + assert.equal( + unprefixedGridCols, + "grid-cols-1", + `expected unprefixed grid-cols-1 (mobile fallback), got className="${cardGridClassName}"` + ); + assert.match(cardGridClassName!, /\bsm:grid-cols-2\b/, "expected sm:grid-cols-2 to be preserved"); +}); diff --git a/tests/unit/reasoningContentInjector.test.ts b/tests/unit/reasoningContentInjector.test.ts new file mode 100644 index 0000000000..134843e774 --- /dev/null +++ b/tests/unit/reasoningContentInjector.test.ts @@ -0,0 +1,44 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + isThinkingMessageModel, + injectReasoningContentForThinkingModel, +} from "../../open-sse/utils/reasoningContentInjector.ts"; + +describe("reasoningContentInjector — xiaomi-tokenplan mimo family (9router#1321)", () => { + it("recognizes xiaomi-tokenplan/mimo-v2.5-pro as a thinking-mode model", () => { + assert.equal(isThinkingMessageModel("xiaomi-tokenplan/mimo-v2.5-pro"), true); + }); + + it("recognizes bare mimo model ids as thinking-mode models", () => { + assert.equal(isThinkingMessageModel("mimo-v2.5-pro"), true); + }); + + it("still recognizes the existing thinking-mode families (deepseek/kimi/k2/minimax)", () => { + assert.equal(isThinkingMessageModel("deepseek-v4-flash"), true); + assert.equal(isThinkingMessageModel("kimi-k2"), true); + assert.equal(isThinkingMessageModel("minimax-m2"), true); + }); + + it("does not flag unrelated model ids", () => { + assert.equal(isThinkingMessageModel("gpt-4o"), false); + }); + + it("injects a reasoning_content placeholder for assistant messages when routed to mimo", () => { + const body = { + model: "xiaomi-tokenplan/mimo-v2.5-pro", + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + ], + }; + + // Simulate the executor gate: only inject when the model is a thinking model. + assert.equal(isThinkingMessageModel(body.model), true); + + const result = injectReasoningContentForThinkingModel(body) as typeof body; + const assistantMsg = result.messages[1] as Record; + assert.equal(assistantMsg.reasoning_content, " "); + }); +}); diff --git a/tests/unit/refactor-opencodeHeaders.test.ts b/tests/unit/refactor-opencodeHeaders.test.ts index b79865b30e..19a5a1cdf6 100644 --- a/tests/unit/refactor-opencodeHeaders.test.ts +++ b/tests/unit/refactor-opencodeHeaders.test.ts @@ -116,6 +116,43 @@ describe("forwardOpencodeClientHeaders – x-opencode-* headers", () => { }); }); +// ── agent metadata headers (X-Session-ID / X-Title) — 9router#2413 ───────── +// Non-OpenCode agent clients (e.g. custom providers) commonly send X-Session-ID +// and X-Title for upstream request tracking/attribution. These were previously +// dropped for every client outside the x-opencode-* allowlist. + +describe("forwardOpencodeClientHeaders – X-Session-ID / X-Title", () => { + it("forwards X-Session-ID from client headers", () => { + const headers = h(); + const clientHeaders = { "X-Session-ID": "sess-xyz" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-session-id"], "sess-xyz"); + }); + + it("forwards X-Title from client headers", () => { + const headers = h(); + const clientHeaders = { "X-Title": "My Agent" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-title"], "My Agent"); + }); + + it("matches X-Session-ID / X-Title case-insensitively", () => { + const headers = h(); + const clientHeaders = { "x-session-id": "sess-lower", "x-title": "lower title" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-session-id"], "sess-lower"); + assert.equal(headers["x-title"], "lower title"); + }); + + it("still does NOT forward unrelated unknown headers", () => { + const headers = h(); + const clientHeaders = { "X-Session-ID": "sess-1", "X-Random-Other": "nope" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-session-id"], "sess-1"); + assert.equal(headers["X-Random-Other"], undefined); + }); +}); + // ── synthesizeRequestId ───────────────────────────────────────────────────── describe("forwardOpencodeClientHeaders – synthesizeRequestId", () => { diff --git a/tests/unit/rehome-open-prs.test.ts b/tests/unit/rehome-open-prs.test.ts new file mode 100644 index 0000000000..5b5c7d23df --- /dev/null +++ b/tests/unit/rehome-open-prs.test.ts @@ -0,0 +1,56 @@ +// Guard for scripts/release/rehome-open-prs.mjs — the parallel-cycle PR re-home +// (generate-release Phase 0a.0b step 3). +// +// The script exists because `gh pr edit --base` fails SILENTLY (v3.8.42): it exits 0 +// while leaving the base untouched. The network side of that is verified by a read-back +// in the script itself; what is testable here is the decision — which PRs get retargeted +// and which are left alone. Getting that wrong in either direction is expensive: +// - retargeting a PR that was never on the frozen branch drags unrelated work into the cycle +// - skipping one strands a contributor on a branch the captain has taken over + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { classify } from "../../scripts/release/rehome-open-prs.mjs"; + +const FROZEN = "release/v3.8.49"; +const NEXT = "release/v3.8.50"; + +test("retargets an open PR sitting on the frozen release branch", () => { + const { action } = classify( + { number: 1, baseRefName: FROZEN, isDraft: false }, + FROZEN, + NEXT + ); + assert.equal(action, "retarget"); +}); + +test("retargets a DRAFT on the frozen branch — a draft still needs a live base", () => { + const { action } = classify({ number: 2, baseRefName: FROZEN, isDraft: true }, FROZEN, NEXT); + assert.equal(action, "retarget"); +}); + +test("skips a PR already re-homed — the script must be idempotent for a resumed release", () => { + const { action, reason } = classify( + { number: 3, baseRefName: NEXT, isDraft: false }, + FROZEN, + NEXT + ); + assert.equal(action, "skip"); + assert.match(reason, /already re-homed/); +}); + +test("never touches a PR based on main — that is the release PR's own lane", () => { + const { action, reason } = classify({ number: 4, baseRefName: "main", isDraft: false }, FROZEN, NEXT); + assert.equal(action, "skip"); + assert.match(reason, /not the frozen branch/); +}); + +test("never touches a PR based on an older, already-shipped release", () => { + const { action } = classify( + { number: 5, baseRefName: "release/v3.8.47", isDraft: false }, + FROZEN, + NEXT + ); + assert.equal(action, "skip"); +}); diff --git a/tests/unit/service-backends.test.ts b/tests/unit/service-backends.test.ts new file mode 100644 index 0000000000..e04aa40010 --- /dev/null +++ b/tests/unit/service-backends.test.ts @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + isServiceBackendPluginId, + getServiceToolFromPluginId, + SERVICE_BACKEND_PLUGIN_IDS, +} from "../../src/lib/services/serviceBackends"; + +test("service backend helper recognizes embedded service plugin ids", () => { + for (const pluginId of SERVICE_BACKEND_PLUGIN_IDS) { + assert.equal(isServiceBackendPluginId(pluginId), true); + } +}); + +test("service backend helper maps plugin ids to runtime tool ids", () => { + assert.equal(getServiceToolFromPluginId("9router"), "9router"); + assert.equal(getServiceToolFromPluginId("cliproxyapi"), "cliproxy"); + assert.equal(getServiceToolFromPluginId("native"), undefined); +}); diff --git a/tests/unit/services/end-to-end-shape.test.ts b/tests/unit/services/end-to-end-shape.test.ts index 80b96725e7..2e27739869 100644 --- a/tests/unit/services/end-to-end-shape.test.ts +++ b/tests/unit/services/end-to-end-shape.test.ts @@ -328,6 +328,7 @@ describe("cliproxy — response shapes", () => { assert.ok("installedVersion" in b, "cliproxy status must include installedVersion"); assert.ok("updateAvailable" in b, "cliproxy status must include updateAvailable"); assert.ok("autoStart" in b, "cliproxy status must include autoStart"); + assert.ok("providerExpose" in b, "cliproxy status must include providerExpose"); assert.ok(typeof b.updateAvailable === "boolean", "updateAvailable must be boolean"); }); }); diff --git a/tests/unit/services/installers/ninerouter.test.ts b/tests/unit/services/installers/ninerouter.test.ts index fd56a9dd0d..4554e34d3e 100644 --- a/tests/unit/services/installers/ninerouter.test.ts +++ b/tests/unit/services/installers/ninerouter.test.ts @@ -7,6 +7,7 @@ import { execSync } from "node:child_process"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-installer-")); const FAKE_BIN_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-fake-bin-")); +const MOCK_NINEROUTER_VERSION = "0.5.30"; process.env.DATA_DIR = TEST_DATA_DIR; process.env.NODE_ENV = "test"; @@ -34,12 +35,12 @@ if [ "$CMD" = "install" ]; then if [ -z "$PREFIX" ]; then PREFIX="$npm_config_prefix"; fi PKG_DIR="$PREFIX/node_modules/9router" mkdir -p "$PKG_DIR/app" - echo '{"name":"9router","version":"0.4.59"}' > "$PKG_DIR/package.json" + echo '{"name":"9router","version":"${MOCK_NINEROUTER_VERSION}"}' > "$PKG_DIR/package.json" touch "$PKG_DIR/app/server.js" exit 0 fi if [ "$CMD" = "view" ]; then - echo "0.4.59" + echo "${MOCK_NINEROUTER_VERSION}" exit 0 fi exit 0 @@ -76,7 +77,7 @@ test.after(() => { }); test("install creates package.json structure", async () => { - const result = await install("0.4.59"); + const result = await install(MOCK_NINEROUTER_VERSION); // Host package.json must exist const hostPkg = path.join(NINEROUTER_INSTALL_DIR, "package.json"); @@ -88,19 +89,19 @@ test("install creates package.json structure", async () => { assert.equal(parsedHost.name, "omniroute-9router-host"); assert.ok(parsedHost.private); - assert.equal(result.installedVersion, "0.4.59"); + assert.equal(result.installedVersion, MOCK_NINEROUTER_VERSION); assert.equal(result.installPath, NINEROUTER_INSTALL_DIR); assert.ok(result.durationMs >= 0); }); test("install captures real version from node_modules/9router/package.json", async () => { const ver = await getInstalledVersion(); - assert.equal(ver, "0.4.59", "should read version from installed package"); + assert.equal(ver, MOCK_NINEROUTER_VERSION, "should read version from installed package"); }); test("update calls npm install with latest (idempotent)", async () => { const result = await update(); - assert.equal(result.installedVersion, "0.4.59"); + assert.equal(result.installedVersion, MOCK_NINEROUTER_VERSION); }); test("uninstall removes node_modules and marks not_installed in DB", async () => { @@ -127,7 +128,7 @@ test("uninstall removes node_modules and marks not_installed in DB", async () => test("getLatestVersion returns version string from npm view", async () => { const ver = await getLatestVersion(); - assert.equal(ver, "0.4.59"); + assert.equal(ver, MOCK_NINEROUTER_VERSION); }); test("resolveSpawnArgs returns expected env and command", () => { diff --git a/tests/unit/shared/components/ProxyConfigModal.test.tsx b/tests/unit/shared/components/ProxyConfigModal.test.tsx index 12299359d1..a63ebe9801 100644 --- a/tests/unit/shared/components/ProxyConfigModal.test.tsx +++ b/tests/unit/shared/components/ProxyConfigModal.test.tsx @@ -389,3 +389,66 @@ describe("ProxyConfigModal custom registry saves", () => { ).toBe(false); }); }); + +describe("ProxyConfigModal test connection (saved proxy)", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + fetchCalls = []; + }); + + afterEach(() => { + while (cleanupCallbacks.length > 0) { + cleanupCallbacks.pop()?.(); + } + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("includes proxyId when testing a saved SOCKS5 registry proxy so the server can load its stored credentials", async () => { + installFetchMock((url, init) => { + const method = String(init?.method || "GET").toUpperCase(); + if (method === "GET" && url === "/api/settings/proxies") { + return { + body: { + items: [ + { + id: "socks5-1", + name: "Geonode SOCKS5", + type: "socks5", + host: "proxy.geonode.io", + port: 12000, + username: "***", + password: "***", + source: "manual", + }, + ], + total: 1, + socks5Enabled: true, + }, + }; + } + if (url.startsWith("/api/settings/proxies/assignments?") && url.includes("scope=provider")) { + return { + body: { items: [{ proxyId: "socks5-1", scope: "provider", scopeId: "claude" }], total: 1 }, + }; + } + if (method === "POST" && url === "/api/settings/proxy/test") { + return { body: { success: true, publicIp: "1.2.3.4", latencyMs: 500 } }; + } + return defaultProxyConfigResponses(url) || { status: 404, body: {} }; + }); + + const { container } = await renderProxyConfigModal(); + await clickButton(container, "testConnection"); + await waitForCall((call) => call.method === "POST" && call.url === "/api/settings/proxy/test"); + + const testCall = fetchCalls.find( + (call) => call.method === "POST" && call.url === "/api/settings/proxy/test" + ); + expect(testCall).toBeTruthy(); + expect(testCall?.body?.proxyId).toBe("socks5-1"); + }, 20000); +}); diff --git a/tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts b/tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts new file mode 100644 index 0000000000..b69784738e --- /dev/null +++ b/tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// #7003 — the RED/GREEN spec in main-server-keepalive-timeout-7003.test.ts proves +// getMainServerTimeoutConfig() raises keepAliveTimeout/headersTimeout above Node's +// unconfigured 5_000ms default, and that the original fix wired it into +// scripts/dev/run-next.mjs. But run-next.mjs only runs `npm run dev`/`npm start` +// from a source checkout. The server real end users run — `omniroute serve` +// (npm-installed CLI), Docker, and Electron — spawns the standalone Next build's +// server.js via scripts/dev/run-standalone.mjs, which prefers server-ws.mjs +// (built from scripts/dev/standalone-server-ws.mjs, copied byte-for-byte into +// dist/server-ws.mjs by scripts/build/assembleStandalone.mjs) over the bare +// server.js specifically because it wraps `http.createServer` with production +// behavior the bare server lacks (peer-IP stamping, method/HEAD guards, WS +// proxying, TLS). Before this fix, that wrapper left Node's http.Server +// keepAliveTimeout/headersTimeout at their unconfigured defaults, so the +// JetBrains AI Assistant reconnect bug reproduced by main-server-keepalive-timeout +// -7003.test.ts still hit the production entry point every real user runs. +// +// standalone-server-ws.mjs has top-level side effects (monkeypatches +// http.createServer, generates a random UUID, and unconditionally +// `await import("./server.js")` — a file that only exists in the assembled +// standalone output, not in the source tree) so it cannot be imported +// in-process. Guard the fix by inspecting the source, mirroring the pattern +// used for run-next.mjs in run-next-node-env.test.ts. +const here = path.dirname(fileURLToPath(import.meta.url)); +const source = fs.readFileSync( + path.resolve(here, "../../scripts/dev/standalone-server-ws.mjs"), + "utf8" +); + +test("standalone-server-ws.mjs imports getMainServerTimeoutConfig", () => { + // The wrapper must import the SIBLING ./main-server-timeouts.mjs — a + // ../../src/... import escapes the package after the dist-root copy and + // crashed every packed boot (2026-07-15, #7065 class). Parity with the + // canonical runtimeTimeouts.ts is guarded by main-server-timeouts-parity.test.ts. + assert.match( + source, + /import\s*\{\s*getMainServerTimeoutConfig\s*\}\s*from\s*["']\.\/main-server-timeouts\.mjs["']/, + "expected the production server wrapper to import getMainServerTimeoutConfig " + + "from its shipped sibling module (./main-server-timeouts.mjs)" + ); +}); + +test("standalone-server-ws.mjs applies keepAliveTimeout/headersTimeout to the wrapped server", () => { + assert.match( + source, + /server\.keepAliveTimeout\s*=\s*\w*[Tt]imeouts?\.keepAliveTimeoutMs/, + "expected the wrapped server object to have keepAliveTimeout set from getMainServerTimeoutConfig()" + ); + assert.match( + source, + /server\.headersTimeout\s*=\s*\w*[Tt]imeouts?\.headersTimeoutMs/, + "expected the wrapped server object to have headersTimeout set from getMainServerTimeoutConfig()" + ); +}); + +test("keepAliveTimeout/headersTimeout are applied inside createServerWithResponsesWs, before the server is returned", () => { + const factoryIdx = source.search(/function createServerWithResponsesWs/); + const keepAliveIdx = source.search(/server\.keepAliveTimeout\s*=/); + const returnIdx = source.search(/return server;/); + + assert.ok(factoryIdx !== -1, "expected createServerWithResponsesWs to exist"); + assert.ok(keepAliveIdx !== -1, "expected a server.keepAliveTimeout assignment to exist"); + assert.ok(returnIdx !== -1, "expected the wrapped server to be returned"); + assert.ok( + keepAliveIdx > factoryIdx, + "timeout wiring must happen inside createServerWithResponsesWs" + ); + assert.ok( + keepAliveIdx < returnIdx, + "timeout wiring must happen before the server object is returned to the caller" + ); +}); diff --git a/tests/unit/stream-utilities.test.ts b/tests/unit/stream-utilities.test.ts index 0f3404869c..33a1b2bdd7 100644 --- a/tests/unit/stream-utilities.test.ts +++ b/tests/unit/stream-utilities.test.ts @@ -133,7 +133,16 @@ test("createPassthroughStreamWithLogger synthesizes reasoning summary events fro assert.match(result, /event: response\.output_item\.done/); }); -test("createPassthroughStreamWithLogger shows a placeholder for encrypted reasoning items", async () => { +// Reconciles #7095 (xz-dev — chat clients never saw ANY signal that Codex was +// reasoning when the upstream Responses API exposed only encrypted private +// reasoning) with #7176 (JxnLexn — mutating `item.summary` with a fabricated +// placeholder corrupted the response item forwarded downstream, discarding the +// `encrypted_content` shape Codex needs for follow-up requests). Both goals are +// satisfied simultaneously: the client-facing synthetic delta/part events still +// carry the placeholder text, but the forwarded `response.output_item.done` +// payload is untouched — `encrypted_content` survives and no `summary` is +// fabricated onto the wire item. +test("createPassthroughStreamWithLogger shows a placeholder for encrypted reasoning items without mutating the forwarded item", async () => { const transform = createPassthroughStreamWithLogger( "codex", null, @@ -169,12 +178,77 @@ test("createPassthroughStreamWithLogger shows a placeholder for encrypted reason result += decoder.decode(value); } + // #7095: chat clients still see the placeholder via the synthetic events. assert.match(result, /event: response\.reasoning_summary_text\.delta/); assert.match(result, /Codex is reasoning/); - assert.match(result, /"summary":\[\{"type":"summary_text","text":"Codex is reasoning/); + + // #7176: the forwarded response.output_item.done payload is untouched — + // encrypted_content survives intact and no fabricated `summary` is present. + assert.match(result, /"encrypted_content":"enc_opaque_state"/); + assert.doesNotMatch(result, /"summary":/); assert.match(result, /event: response\.output_item\.done/); }); +// Companion guard for the test above. The output_item.done line is echoed +// verbatim, so a re-introduced `item.summary` mutation would NOT surface there. +// It does surface here: the reasoning item is captured into +// passthroughResponsesOutputItems and re-serialized into the response.completed +// snapshot when upstream sends an empty `output` (store: false). This is the +// path that actually fails if the mutation comes back — it is what makes the +// #7176 half of the reconciliation enforceable rather than incidental. +test("createPassthroughStreamWithLogger backfills completed output with encrypted reasoning unmutated", async () => { + const transform = createPassthroughStreamWithLogger( + "codex", + null, + null, + "gpt-5.5-low", + null, + null, + null, + null, + null, + "openai-responses" + ); + + const writer = transform.writable.getWriter(); + await writer.write( + new TextEncoder().encode( + [ + "event: response.output_item.done", + 'data: {"type":"response.output_item.done","response_id":"resp_reasoning_3","output_index":0,"item":{"id":"rs_resp_reasoning_3_0","type":"reasoning","encrypted_content":"enc_opaque_state"}}', + "", + "event: response.completed", + 'data: {"type":"response.completed","response":{"id":"resp_reasoning_3","model":"gpt-5.5-low","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}', + "", + ].join("\n") + ) + ); + await writer.close(); + + const reader = transform.readable.getReader(); + const decoder = new TextDecoder(); + let result = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + result += decoder.decode(value); + } + + // The placeholder still reached the client (#7095). + assert.match(result, /event: response\.reasoning_summary_text\.delta/); + assert.match(result, /Codex is reasoning/); + + // The item re-serialized into the completed snapshot carries the original + // encrypted_content and never a fabricated summary (#7176). + const completed = result + .split(/\n\n+/) + .find((block) => block.includes('"type":"response.completed"')); + assert.ok(completed, "expected a response.completed event on the wire"); + assert.match(completed, /"encrypted_content":"enc_opaque_state"/); + assert.doesNotMatch(completed, /"summary":/); +}); + test("createPassthroughStreamWithLogger backfills completed output from function_call arguments events", async () => { const transform = createPassthroughStreamWithLogger( "codex", diff --git a/tests/unit/streaming-empty-content-block-1382.test.ts b/tests/unit/streaming-empty-content-block-1382.test.ts new file mode 100644 index 0000000000..750d7d673a --- /dev/null +++ b/tests/unit/streaming-empty-content-block-1382.test.ts @@ -0,0 +1,138 @@ +/** + * Issue #1382 (upstream decolua/9router) — a streaming Claude response that + * opens a `content_block_start` (type "text", initial text "") and then + * immediately `content_block_stop`s WITHOUT ever emitting a + * `content_block_delta` carrying real text/tool_use content must be treated + * as an empty/malformed response, not a valid completion. + * + * Before this fix, `validateResponseQuality`'s bounded SSE peek stopped + * buffering (and reported `valid: true`) as soon as ANY content_block_* + * event was observed — including a content_block_start whose block never + * carries usable text. Tool-heavy requests against backends that mishandle + * tool definitions (reported: DeepSeek, GLM via claude→openai translation) + * can emit exactly this shape: a lifecycle that "completes" successfully at + * the transport layer while the client receives no usable content. The + * combo loop never saw this as a failure, so no failover to the next model + * in the combo ever happened. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { validateResponseQuality } = await import("../../open-sse/services/combo.ts"); + +const encoder = new TextEncoder(); +const silentLog = { warn: () => {} }; + +function claudeSseStream(events: string[]): ReadableStream { + const body = events.join("\n") + "\n"; + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(body)); + controller.close(); + }, + }); +} + +/** + * Build a mock Claude 200 streaming response with a content_block_start/stop + * pair carrying EMPTY text and no tool_use block — the shape reported in + * #1382 for tool-heavy claude→openai requests against DeepSeek/GLM. + */ +function makeEmptyTextBlockStream(): Response { + const events = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_test_1382", + type: "message", + role: "assistant", + model: "deepseek-v4-pro-max", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 19882, output_tokens: 0 }, + }, + })}`, + "", + `event: content_block_start\ndata: ${JSON.stringify({ + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + })}`, + "", + `event: content_block_stop\ndata: ${JSON.stringify({ + type: "content_block_stop", + index: 0, + })}`, + "", + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { input_tokens: 0, output_tokens: 25 }, + })}`, + "", + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}`, + "", + ]; + + return new Response(claudeSseStream(events), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +test("#1382 streaming Claude response with empty content_block (no text, no tool_use) is marked invalid", async () => { + const res = makeEmptyTextBlockStream(); + const out = await validateResponseQuality(res, true, silentLog); + assert.equal( + out.valid, + false, + `expected invalid for empty content_block stream, got valid=true (reason: ${out.reason})` + ); + assert.match(out.reason ?? "", /empty/i, `reason should mention 'empty', got: "${out.reason}"`); +}); + +test("#1382 streaming Claude response with a real tool_use content_block_start remains valid", async () => { + const events = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_test_1382_tool", + type: "message", + role: "assistant", + model: "deepseek-v4-pro-max", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 100, output_tokens: 0 }, + }, + })}`, + "", + `event: content_block_start\ndata: ${JSON.stringify({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_1", name: "Bash", input: {} }, + })}`, + "", + `event: content_block_stop\ndata: ${JSON.stringify({ + type: "content_block_stop", + index: 0, + })}`, + "", + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "tool_use", stop_sequence: null }, + usage: { input_tokens: 0, output_tokens: 12 }, + })}`, + "", + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}`, + "", + ]; + const res = new Response(claudeSseStream(events), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + const out = await validateResponseQuality(res, true, silentLog); + assert.equal(out.valid, true, `expected valid for tool_use stream, got invalid: ${out.reason}`); + assert.ok(out.clonedResponse, "clonedResponse must be present for valid streaming response"); +}); diff --git a/tests/unit/sync-next-cycle.test.ts b/tests/unit/sync-next-cycle.test.ts index 8fdbb8b429..fb5ecfdf15 100644 --- a/tests/unit/sync-next-cycle.test.ts +++ b/tests/unit/sync-next-cycle.test.ts @@ -123,3 +123,31 @@ test("i18n resync also propagates the FINALIZED [prevVersion] section into the m "syncs the shipped (finalized) section — without this all 42 mirrors keep it as TBD" ); }); + +// WS0.3 (v3.8.49 quality plan): the captain's sync-back push is the one write path +// with NO CI gate — the merged tree must pass release-green --quick BEFORE the push, +// or the whole PR queue inherits a red tip (G1). --skip-green-gate is the documented +// emergency escape hatch (pre-existing tip reds verified by hand). + +test("greenGateArgs returns the quick release-green command by default", async () => { + const { greenGateArgs } = await import("../../scripts/release/sync-next-cycle.mjs"); + assert.deepEqual(greenGateArgs(["node", "script", "3.8.49"]), [ + "scripts/quality/validate-release-green.mjs", + "--quick", + ]); +}); + +test("greenGateArgs returns null only with the explicit --skip-green-gate flag", async () => { + const { greenGateArgs } = await import("../../scripts/release/sync-next-cycle.mjs"); + assert.equal(greenGateArgs(["node", "script", "3.8.49", "--skip-green-gate"]), null); + assert.notEqual(greenGateArgs(["node", "script", "3.8.49", "--other"]), null); +}); + +test("sync-next-cycle gates the push on release-green (source guard)", () => { + const src = readFileSync(SCRIPT_PATH, "utf8"); + const mainIdx = src.indexOf("function main()"); + const gateCallIdx = src.indexOf("greenGateArgs(process.argv)", mainIdx); + const pushIdx = src.indexOf('git(["push", "origin", BRANCH]'); + assert.ok(gateCallIdx > mainIdx, "main() must call greenGateArgs(process.argv)"); + assert.ok(pushIdx > gateCallIdx, "the release-green gate must run BEFORE the push"); +}); diff --git a/tests/unit/t25-provider-validation-modelid-fallback.test.ts b/tests/unit/t25-provider-validation-modelid-fallback.test.ts index 7912d30d96..03b1f68c0b 100644 --- a/tests/unit/t25-provider-validation-modelid-fallback.test.ts +++ b/tests/unit/t25-provider-validation-modelid-fallback.test.ts @@ -114,3 +114,50 @@ test("T25: fallback chat probe treats 429 as valid credentials with warning", as globalThis.fetch = originalFetch; } }); + +// decolua/9router#2032: OpenAI-compatible "Check" silently passed for ANY +// non-empty Model ID because a chat-probe 404 (model_not_found) fell through +// the generic "4xx other than auth" branch with no warning. The user only +// discovered the bad model id after a real request tripped the per-model +// lockout. A 404 must surface a warning at Check time instead of a bare pass. +test("T25 / #2032: fallback chat probe surfaces a warning on 404 model_not_found", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = async (url) => { + if (String(url).endsWith("/models")) { + return new Response(JSON.stringify({ error: "Not Found" }), { status: 404 }); + } + return new Response( + JSON.stringify({ + error: { + message: "The model glm-5.2 does not exist.", + type: "invalid_request_error", + param: null, + code: "model_not_found", + }, + }), + { status: 404 } + ); + }; + + try { + const result = await validateProviderApiKey({ + provider: "openai-compatible-chat-t25-model-not-found", + apiKey: "sk-test", + providerSpecificData: { + baseUrl: "https://api.example.com/v1", + validationModelId: "glm-5.2", + }, + }); + + // Credentials themselves are fine (404 is not an auth failure), so this + // still resolves as valid — but MUST carry an actionable warning instead + // of a silent pass, so the user learns about the bad model id at Check + // time rather than after the first real request gets locked out. + assert.equal(result.valid, true); + assert.equal(result.method, "inference_available"); + assert.match(result.warning, /model.*(?:not found|does not exist|glm-5\.2)/i); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/ui/CliAgentsPage.test.tsx b/tests/unit/ui/CliAgentsPage.test.tsx index 82f4a5205f..257f5e4149 100644 --- a/tests/unit/ui/CliAgentsPage.test.tsx +++ b/tests/unit/ui/CliAgentsPage.test.tsx @@ -54,12 +54,18 @@ const { default: CliAgentsPageClient } = await import( // ── Fixtures ────────────────────────────────────────────────────────────────── -/** 6 agent tool ids from the catalog (§3.2 of plan-14) */ +/** + * Agent tool ids from the catalog (§3.2 of plan-14, category: "agent" in + * src/shared/constants/cliTools.ts). "omp" and "letta" were added to the + * catalog after plan-14 shipped, bringing the count from 6 to 8. + */ const AGENT_IDS = [ "openclaw", "hermes-agent", "goose", "interpreter", + "omp", + "letta", "warp", "agent-deck", ] as const; @@ -144,9 +150,9 @@ describe("CliAgentsPageClient", () => { expect(container.textContent).toContain("pageTitle"); }, 15000); - it("2. renders exactly 6 agent tool cards", async () => { + it("2. renders exactly 8 agent tool cards", async () => { const container = await renderPage(); - expect(countAgentCards(container)).toBe(6); + expect(countAgentCards(container)).toBe(8); }, 15000); it("3. search filter — 'hermes' shows 1 card (hermes-agent)", async () => { diff --git a/tests/unit/ui/HermesAgentToolCard-model-aliases.test.tsx b/tests/unit/ui/HermesAgentToolCard-model-aliases.test.tsx new file mode 100644 index 0000000000..5d58e13640 --- /dev/null +++ b/tests/unit/ui/HermesAgentToolCard-model-aliases.test.tsx @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +// Regression probe for issue #7151: OpenRouter (and every other +// `passthroughModels` provider — requesty, dgrid, agentrouter, charm-hyper, +// etc.) never appears in the Hermes Agent role model picker. +// +// Root cause: derives a passthrough provider's model list +// from the `modelAliases` prop (ModelSelectModal.tsx groupedModels → +// buildPassthroughAliasModels(modelAliases, providerId)). When `modelAliases` +// is `{}` (the component default), that helper returns `[]` and the provider +// group is skipped entirely — see modelSelectModalHelpers.ts. Every sibling +// CLI tool card (Codex, Claude, Cline, Kilo, Droid, OpenClaw, Antigravity) +// fetches `/api/models/alias` and passes the result through, but +// HermesAgentToolCard never does, so OpenRouter's managed-available-model +// aliases (synced automatically after the connection is tested — see +// syncManagedAvailableModelAliases in src/lib/providerModels/managedAvailableModels.ts) +// are invisible to it. + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CARD_PATH = resolve( + __dirname, + "../../../src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx" +); + +describe("HermesAgentToolCard model alias wiring (#7151)", () => { + const source = readFileSync(CARD_PATH, "utf8"); + + it("declares modelAliases state", () => { + expect(source).toMatch(/const \[modelAliases, setModelAliases\] = useState\(\{\}\)/); + }); + + it("fetches /api/models/alias when expanded", () => { + expect(source).toContain('fetch("/api/models/alias")'); + }); + + it("passes modelAliases prop to ModelSelectModal", () => { + // Regression guard: this prop is what unlocks passthrough provider groups + // (OpenRouter, Requesty, DGrid, AgentRouter, Charm Hyper, ...) in the + // Hermes Agent role picker. Without it, OpenRouter is silently absent + // from the "Select" modal for every role (Default, Delegation, ...). + expect(source).toMatch(/modelAliases=\{modelAliases\}/); + }); +}); diff --git a/tests/unit/ui/agent-bridge-server-card-a11y.test.tsx b/tests/unit/ui/agent-bridge-server-card-a11y.test.tsx index 9db239e614..b1892179c6 100644 --- a/tests/unit/ui/agent-bridge-server-card-a11y.test.tsx +++ b/tests/unit/ui/agent-bridge-server-card-a11y.test.tsx @@ -2,7 +2,7 @@ * a11y tests for AgentBridgeServerCard — each action button must have aria-label. * Uses source-text inspection (no JSDOM render needed) for the structural assertion. */ -import { describe, it } from "node:test"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; diff --git a/tests/unit/ui/cli-tools-no-mitm-tab.test.tsx b/tests/unit/ui/cli-tools-no-mitm-tab.test.tsx index bce2f28fcc..cba7c99ffe 100644 --- a/tests/unit/ui/cli-tools-no-mitm-tab.test.tsx +++ b/tests/unit/ui/cli-tools-no-mitm-tab.test.tsx @@ -4,7 +4,7 @@ * * Uses source-text inspection — no JSDOM render needed. */ -import { describe, it } from "node:test"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; diff --git a/tests/unit/ui/compressionHub-context-editing.test.tsx b/tests/unit/ui/compressionHub-context-editing.test.tsx index 88fee2e490..6abeb667b6 100644 --- a/tests/unit/ui/compressionHub-context-editing.test.tsx +++ b/tests/unit/ui/compressionHub-context-editing.test.tsx @@ -139,8 +139,11 @@ describe("CompressionHub — Context Editing", () => { }); await flush(); + // CompressionHub deliberately does NOT use useTranslations (see the + // hydration note at the top of CompressionHub.tsx) — its strings are + // literal English text, exactly like EngineConfigPage. const text = container.textContent ?? ""; - expect(text).toContain("Compressão delegada ao provedor"); + expect(text).toContain("Provider-delegated compression"); expect(text).toContain("Context Editing (Claude)"); }); @@ -156,8 +159,8 @@ describe("CompressionHub — Context Editing", () => { await flush(); const text = container.textContent ?? ""; - expect(text).toContain("apenas para Claude"); - expect(text).toContain("não reescrevemos a mensagem"); + expect(text).toContain("available for Claude (Anthropic) only"); + expect(text).toContain("we do not rewrite the message"); }); it("PUTs contextEditing: { enabled: true } when the toggle is flipped on", async () => { diff --git a/tests/unit/ui/compressionHub.test.tsx b/tests/unit/ui/compressionHub.test.tsx index 166c0fef04..27d380d3f0 100644 --- a/tests/unit/ui/compressionHub.test.tsx +++ b/tests/unit/ui/compressionHub.test.tsx @@ -111,28 +111,13 @@ async function flush() { // ── Tests ───────────────────────────────────────────────────────────────── describe("CompressionHub", () => { - it("renders the master switch, mode selector, and the layered pipeline", async () => { - setupFetchMock({ enabled: true, mode: "stacked", pipeline: [{ engine: "rtk" }] }); - const { default: CompressionHub } = - await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub"); - - let container!: HTMLElement; - await act(async () => { - container = mountInContainer(); - }); - await flush(); - - const text = container.textContent ?? ""; - expect(text).toContain("Compression Hub"); - expect(text).toContain("Token Saver"); - expect(text).toContain("Stacked"); - // Active pipeline engine (from the default combo) renders - expect(text).toContain("RTK"); - // Inactive engines from the catalog render too - expect(text).toContain("Caveman"); - // Active-pipeline callout shows when enabled && stacked - expect(text).toContain("Layer pipeline is active"); - }); + // NOTE: the master Token Saver toggle, mode selector, and layered-pipeline + // preview this describe block used to assert on were removed by the Phase 2 + // Hub redesign (see the "Phase 2" comment at the top of CompressionHub.tsx — + // the Hub is now a thin overview with just an active-profile selector + the + // Context Editing toggle). That redesign, including the explicit assertion + // that the master toggle/mode selector/reorder buttons no longer render, is + // covered by compressionHub-active-selector.test.tsx. it("INVARIANT #1: no per-layer control issues a PUT/POST to /api/context/combos/default", { timeout: 20000 }, async () => { const comboWrites: { method: string }[] = []; @@ -186,22 +171,6 @@ describe("CompressionHub", () => { expect(comboWrites).toHaveLength(0); }); - - it("shows the activation warning when Token Saver is off", async () => { - setupFetchMock({ enabled: false, mode: "off", pipeline: [] }); - const { default: CompressionHub } = - await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub"); - - let container!: HTMLElement; - await act(async () => { - container = mountInContainer(); - }); - await flush(); - - const text = container.textContent ?? ""; - expect(text).toContain("Enable Token Saver"); - expect(text).toContain("only run in Stacked mode"); - }); }); describe("CompressionCombosPageClient", () => { diff --git a/tests/unit/ui/conversation-tab-separators.test.tsx b/tests/unit/ui/conversation-tab-separators.test.tsx index 7faad9b6b1..5723955a56 100644 --- a/tests/unit/ui/conversation-tab-separators.test.tsx +++ b/tests/unit/ui/conversation-tab-separators.test.tsx @@ -3,7 +3,7 @@ * Validates the rendering logic: both sections appear when both request/response have turns; * only CONTEXT HISTORY appears when response is empty. */ -import { describe, it } from "node:test"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; import { normalizeConversation } from "../../../src/mitm/inspector/conversationNormalizer.ts"; import type { InterceptedRequest } from "../../../src/mitm/inspector/types.ts"; diff --git a/tests/unit/ui/conversation-tab.test.tsx b/tests/unit/ui/conversation-tab.test.tsx index 62d405258e..92f8ab213c 100644 --- a/tests/unit/ui/conversation-tab.test.tsx +++ b/tests/unit/ui/conversation-tab.test.tsx @@ -1,7 +1,7 @@ /** * Tests for ConversationTab — normalizeConversation + chat bubble rendering logic */ -import { describe, it } from "node:test"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; import { normalizeConversation } from "../../../src/mitm/inspector/conversationNormalizer.ts"; import type { InterceptedRequest } from "../../../src/mitm/inspector/types.ts"; diff --git a/tests/unit/ui/historic-session-banner.test.tsx b/tests/unit/ui/historic-session-banner.test.tsx index 910178514d..fb27927710 100644 --- a/tests/unit/ui/historic-session-banner.test.tsx +++ b/tests/unit/ui/historic-session-banner.test.tsx @@ -1,7 +1,7 @@ /** * Tests for HistoricSessionBanner — render with sessionName/null + backToLive callback */ -import { describe, it } from "node:test"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; // Pure logic tests — no DOM needed (no next-intl in node:test runner) diff --git a/tests/unit/ui/home-topology-hidden-4596.test.tsx b/tests/unit/ui/home-topology-hidden-4596.test.tsx index cdb3a64662..2c3ba169b6 100644 --- a/tests/unit/ui/home-topology-hidden-4596.test.tsx +++ b/tests/unit/ui/home-topology-hidden-4596.test.tsx @@ -19,6 +19,14 @@ describe("home topology hidden networking (#4596)", () => { globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; websocketMock.mockClear(); + // useLiveDashboard runs a runtime handshake (GET /api/v1/ws?handshake=1) to + // discover the public WS URL before it opens the socket, so it never + // connects to the hardcoded default and then flaps to the public URL. Stub + // it to fail fast instead of letting the real global fetch hit the network. + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("network unavailable in test"))) + ); vi.stubGlobal( "WebSocket", class WebSocketMock { @@ -59,9 +67,14 @@ describe("home topology hidden networking (#4596)", () => { expect(websocketMock).not.toHaveBeenCalled(); }); - it("opens a WebSocket when the topology section is enabled", () => { - act(() => { + it("opens a WebSocket when the topology section is enabled", async () => { + await act(async () => { root!.render(); + // Let the handshake fetch's rejection propagate through .catch()/.finally() + // so `wsUrlResolved` flips to true and the connect effect re-runs. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); }); expect(websocketMock).toHaveBeenCalledTimes(1); }); diff --git a/tests/unit/ui/memories-tab.test.tsx b/tests/unit/ui/memories-tab.test.tsx index cb56eaf143..845e1e4d7e 100644 --- a/tests/unit/ui/memories-tab.test.tsx +++ b/tests/unit/ui/memories-tab.test.tsx @@ -292,18 +292,27 @@ describe("MemoriesTab", () => { }); it("calls DELETE when delete confirmed", async () => { - const mockFetch = vi.fn(); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: MOCK_MEMORIES, - total: 2, - totalPages: 1, - stats: { total: 2, tokensUsed: 0, hitRate: 0, cacheStats: { hits: 0, misses: 0 } }, - }), + // MemoriesTab fires two independent fetches on mount: an immediate health + // check (/api/memory/health) and a 300ms-debounced memories list fetch + // (/api/memory?...). A call-order-dependent mock (mockResolvedValueOnce + + // fallback) is fragile here because the health check resolves first and + // would consume the "once" response meant for the list. Key off the URL + // instead, like the rest of this file's fetch mocks do. + const mockFetch = vi.fn((url: string) => { + if (typeof url === "string" && url.startsWith("/api/memory?")) { + return Promise.resolve({ + ok: true, + json: async () => ({ + data: MOCK_MEMORIES, + total: 2, + totalPages: 1, + stats: { total: 2, tokensUsed: 0, hitRate: 0, cacheStats: { hits: 0, misses: 0 } }, + }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); }); - mockFetch.mockResolvedValue({ ok: true, json: async () => ({}) }); - globalThis.fetch = mockFetch; + globalThis.fetch = mockFetch as unknown as typeof fetch; const { default: MemoriesTab } = await import( "../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab" ); diff --git a/tests/unit/ui/model-select-modal-hidden-models-7156.test.tsx b/tests/unit/ui/model-select-modal-hidden-models-7156.test.tsx new file mode 100644 index 0000000000..957f033a7f --- /dev/null +++ b/tests/unit/ui/model-select-modal-hidden-models-7156.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ModelSelectModal from "@/shared/components/ModelSelectModal"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const roots: Array<{ root: ReturnType; el: HTMLDivElement }> = []; + +async function render(props: React.ComponentProps): Promise { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + await act(async () => { + root.render(); + }); + roots.push({ root, el }); + return el; +} + +beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/combos")) return new Response(JSON.stringify({ combos: [] }), { status: 200 }); + if (url.includes("/api/provider-nodes")) return new Response(JSON.stringify({ nodes: [] }), { status: 200 }); + if (url.includes("/api/provider-models")) { + return new Response( + JSON.stringify({ + models: { + requesty: [ + { id: "visible-model-1", name: "Visible Model", source: "imported" }, + { id: "hidden-model-1", name: "Hidden Model", source: "imported", isHidden: true }, + ], + }, + modelCompatOverrides: [], + }), + { status: 200 } + ); + } + return new Response(JSON.stringify({}), { status: 200 }); + }) + ); +}); + +afterEach(() => { + for (const { root, el } of roots.splice(0)) { act(() => root.unmount()); el.remove(); } + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("ModelSelectModal hidden-model filtering (#7156)", () => { + it("does not list a custom model explicitly flagged isHidden:true", async () => { + const el = await render({ + isOpen: true, onClose: vi.fn(), onSelect: vi.fn(), + activeProviders: [{ provider: "requesty", id: "conn-1" }], + modelAliases: {}, title: "Add model to combo", + }); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(el.textContent).toContain("Visible Model"); + expect(el.textContent).not.toContain("Hidden Model"); + }); +}); diff --git a/tests/unit/ui/playground-build-tab.test.tsx b/tests/unit/ui/playground-build-tab.test.tsx index c954679818..2699aa4c53 100644 --- a/tests/unit/ui/playground-build-tab.test.tsx +++ b/tests/unit/ui/playground-build-tab.test.tsx @@ -64,6 +64,52 @@ function renderBuildTab(config = BASE_CONFIG): HTMLDivElement { return el; } +// ── BuildWizard navigation helpers ────────────────────────────────────────── +// +// BuildTab now renders behind a 3-step wizard (BuildWizard.tsx): +// step 1 — pick a mode (Tools / JSON / Both) +// step 2 — configure tools and/or the JSON schema, depending on the mode +// step 3 — run + toolbar badges + prompt textarea +// +// next-intl is mocked as a key pass-through above, so every translated label +// renders as its raw i18n key (e.g. "nextButton", "modeToolsTitle"). + +type BuildMode = "tools" | "json" | "both"; + +function clickNext(el: HTMLDivElement): void { + const nextBtn = Array.from(el.querySelectorAll("button")).find((b) => + b.textContent?.includes("nextButton"), + ) as HTMLButtonElement; + act(() => { + nextBtn.click(); + }); +} + +function selectMode(el: HTMLDivElement, mode: BuildMode): void { + // Step 1's default mode is already "tools" — only click a mode card when a + // different mode is required. + if (mode === "tools") return; + const key = mode === "json" ? "modeJsonTitle" : "modeBothTitle"; + const card = Array.from(el.querySelectorAll("button")).find((b) => + b.textContent?.includes(key), + ) as HTMLButtonElement; + act(() => { + card.click(); + }); +} + +/** Drive the wizard from step 1 to step 2, selecting `mode` along the way. */ +function goToStep2(el: HTMLDivElement, mode: BuildMode = "tools"): void { + selectMode(el, mode); + clickNext(el); +} + +/** Drive the wizard from step 1 all the way to step 3 (run screen). */ +function goToStep3(el: HTMLDivElement, mode: BuildMode = "tools"): void { + goToStep2(el, mode); + clickNext(el); +} + afterEach(() => { for (const { root, el } of containers) { act(() => root.unmount()); @@ -76,28 +122,34 @@ afterEach(() => { describe("BuildTab", () => { it("renders Run button", () => { const el = renderBuildTab(); - const runBtn = el.querySelector("[class*='bg-primary']"); - expect(runBtn?.textContent).toContain("runLabel"); + goToStep3(el); + const runBtn = Array.from(el.querySelectorAll("button")).find((b) => + b.textContent?.includes("runButton"), + ); + expect(runBtn).not.toBeUndefined(); }); it("renders Function calling section", () => { const el = renderBuildTab(); - expect(el.textContent).toContain("Function calling"); + goToStep2(el, "tools"); + expect(el.textContent).toContain("toolsLabel"); expect(el.textContent).toContain("Add tool"); }); it("renders Structured output section", () => { const el = renderBuildTab(); - expect(el.textContent).toContain("Structured output"); + goToStep2(el, "json"); + expect(el.textContent).toContain("structuredOutputLabel"); expect(el.textContent).toContain("JSON mode"); }); it("adds a tool and shows it in function calling UI", async () => { const el = renderBuildTab(); + goToStep2(el, "tools"); - // Find add tool form inputs in the right panel + // Find add tool form inputs in the tools panel const allInputs = el.querySelectorAll("input[type='text']") as NodeListOf; - // The first or second input should be the function name + // The first input is the function name const nameInput = allInputs[0]; act(() => setInputValue(nameInput, "search_web")); @@ -115,16 +167,15 @@ describe("BuildTab", () => { it("shows validation error for invalid JSON in tool params", async () => { const el = renderBuildTab(); + goToStep2(el, "tools"); const allInputs = el.querySelectorAll("input[type='text']") as NodeListOf; act(() => setInputValue(allInputs[0], "bad_tool")); // The parameters textarea is in the Add tool form section — it has default valid JSON. - // We need to find the textarea labeled "JSON schema for parameters" in the add form. const paramsTextareas = Array.from(el.querySelectorAll("textarea")).filter( (t) => t.getAttribute("aria-label") === "JSON schema for parameters", ); - // The last one is in the Add tool form (the first may be the message prompt textarea) const paramsTextarea = paramsTextareas[paramsTextareas.length - 1] as HTMLTextAreaElement; act(() => setInputValue(paramsTextarea, "NOT JSON {{{")); @@ -140,6 +191,7 @@ describe("BuildTab", () => { it("enables JSON mode toggle and shows schema editor", async () => { const el = renderBuildTab(); + goToStep2(el, "json"); const toggle = el.querySelector("[role='switch']") as HTMLButtonElement; expect(toggle).not.toBeNull(); @@ -153,6 +205,7 @@ describe("BuildTab", () => { it("shows tool badge in toolbar when tools are added", async () => { const el = renderBuildTab(); + goToStep2(el, "tools"); const allInputs = el.querySelectorAll("input[type='text']") as NodeListOf; act(() => setInputValue(allInputs[0], "my_tool")); @@ -163,7 +216,9 @@ describe("BuildTab", () => { ) as HTMLButtonElement; await act(async () => { addToolBtn.click(); }); - // Badge "1 tool" should appear in toolbar + clickNext(el); // step 2 -> step 3 + + // Badge "1 tool" should appear in the step-3 toolbar expect(el.textContent).toContain("1 tool"); }); @@ -183,6 +238,7 @@ describe("BuildTab", () => { ); const el = renderBuildTab(); + goToStep2(el, "tools"); // Add a tool const allInputs = el.querySelectorAll("input[type='text']") as NodeListOf; @@ -193,14 +249,16 @@ describe("BuildTab", () => { ) as HTMLButtonElement; await act(async () => { addToolBtn.click(); }); - // Type a prompt - const promptTextarea = el.querySelector("textarea[placeholder*='message']") as HTMLTextAreaElement; + clickNext(el); // step 2 -> step 3 + + // Type a prompt (the only textarea left on step 3 is the prompt input) + const promptTextarea = el.querySelector("textarea") as HTMLTextAreaElement; act(() => setInputValue(promptTextarea, "Run this tool")); - // Click Run (label is "runLabel" via mocked t()) + // Click Run (label is "runButton" via mocked t()) const runBtns = el.querySelectorAll("button"); const runBtn = Array.from(runBtns).find( - (b) => b.textContent?.includes("runLabel") && !b.textContent?.includes("clearAll"), + (b) => b.textContent?.includes("runButton"), ) as HTMLButtonElement; await act(async () => { runBtn.click(); }); await act(async () => { @@ -218,8 +276,12 @@ describe("BuildTab", () => { it("shows JSON mode badge in toolbar when JSON mode is enabled", async () => { const el = renderBuildTab(); + goToStep2(el, "json"); const toggle = el.querySelector("[role='switch']") as HTMLButtonElement; await act(async () => { toggle.click(); }); + + clickNext(el); // step 2 -> step 3 + expect(el.textContent).toContain("JSON mode"); }); }); diff --git a/tests/unit/ui/playground-compare-tab.test.tsx b/tests/unit/ui/playground-compare-tab.test.tsx index bbb12ebe53..82fc3ad0e8 100644 --- a/tests/unit/ui/playground-compare-tab.test.tsx +++ b/tests/unit/ui/playground-compare-tab.test.tsx @@ -92,9 +92,9 @@ function renderCompareTab(config = BASE_CONFIG): HTMLDivElement { return el; } -function setInputValue(el: HTMLInputElement, value: string) { +function setInputValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) { const nativeSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, + el instanceof HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype, "value", )?.set; nativeSetter?.call(el, value); @@ -204,6 +204,10 @@ describe("CompareTab", () => { act(() => setInputValue(input, "model-2")); await act(async () => { addBtn.click(); }); + // Run all is disabled until a prompt is entered + const promptTextarea = el.querySelector("[aria-label='User prompt']") as HTMLTextAreaElement; + act(() => setInputValue(promptTextarea, "Compare this")); + const runBtn = el.querySelector("[aria-label='Run all columns']") as HTMLButtonElement; await act(async () => { runBtn.click(); }); @@ -219,6 +223,11 @@ describe("CompareTab", () => { ); const el = renderCompareTab(); + + // Run all is disabled until a prompt is entered + const promptTextarea = el.querySelector("[aria-label='User prompt']") as HTMLTextAreaElement; + act(() => setInputValue(promptTextarea, "Compare this")); + const runBtn = el.querySelector("[aria-label='Run all columns']") as HTMLButtonElement; act(() => { runBtn.click(); }); diff --git a/tests/unit/ui/playground-studio.test.tsx b/tests/unit/ui/playground-studio.test.tsx index 8a975219ba..91db18dbf8 100644 --- a/tests/unit/ui/playground-studio.test.tsx +++ b/tests/unit/ui/playground-studio.test.tsx @@ -68,6 +68,10 @@ vi.mock("@/shared/components/MonacoEditor", () => ({ vi.mock("@/shared/constants/providers", () => ({ ALIAS_TO_ID: {}, + AI_PROVIDERS: {}, + OPENAI_COMPATIBLE_PREFIX: "openai-compatible-", + ANTHROPIC_COMPATIBLE_PREFIX: "anthropic-compatible-", + CLAUDE_CODE_COMPATIBLE_PREFIX: "anthropic-compatible-cc-", })); vi.mock("@/shared/utils/maskEmail", () => ({ diff --git a/tests/unit/ui/provider-plan-config.test.tsx b/tests/unit/ui/provider-plan-config.test.tsx deleted file mode 100644 index 0351a92a54..0000000000 --- a/tests/unit/ui/provider-plan-config.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -// @vitest-environment jsdom -import React from "react"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("next-intl", () => ({ - useTranslations: () => (key: string) => key, -})); - -vi.mock("@/shared/components", () => ({ - Button: ({ - children, - onClick, - disabled, - }: { - children: React.ReactNode; - onClick?: () => void; - disabled?: boolean; - }) => ( - - ), -})); - -vi.mock("@/shared/components/ProviderIcon", () => ({ - default: () => , -})); - -vi.mock("@/lib/quota/planRegistry", () => ({ - knownProviders: () => ["openai", "anthropic"], - getKnownPlan: (prov: string) => { - if (prov === "openai") { - return { dimensions: [{ unit: "tokens", window: "daily", limit: 100000 }] }; - } - return null; - }, -})); - -const MOCK_CONNECTIONS = [ - { id: "conn_1", provider: "openai", name: "GPT Account" }, - { id: "conn_2", provider: "anthropic", email: "user@example.com" }, -]; - -const mockFetch = vi.fn(); -vi.stubGlobal("fetch", mockFetch); - -const { default: ProviderPlanConfigClient } = await import( - "../../../src/app/(dashboard)/dashboard/costs/quota-share/plans/ProviderPlanConfigClient" -); - -let container: HTMLDivElement | null = null; -let root: ReturnType | null = null; - -async function renderPage() { - (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = - true; - container = document.createElement("div"); - document.body.appendChild(container); - await act(async () => { - root = createRoot(container!); - root.render(); - }); - // Wait for initial fetch effect to resolve - await act(async () => { - await new Promise((r) => setTimeout(r, 30)); - }); -} - -describe("ProviderPlanConfigClient", { timeout: 15000 }, () => { - beforeEach(() => { - mockFetch.mockImplementation((url: string) => { - if (String(url).includes("/api/providers/client")) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ connections: MOCK_CONNECTIONS }), - } as unknown as Response); - } - if (String(url).includes("/api/quota/plans")) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve([]), - } as unknown as Response); - } - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({}), - } as unknown as Response); - }); - }); - - afterEach(() => { - if (root && container) act(() => root!.unmount()); - container?.remove(); - container = null; - root = null; - vi.clearAllMocks(); - }); - - it("renders the page title", async () => { - await renderPage(); - expect(document.body.innerHTML).toContain("title"); - }); - - it("renders catalog section with known providers", async () => { - await renderPage(); - // catalogTitle key should appear - expect(document.body.innerHTML).toContain("catalogTitle"); - expect(document.body.innerHTML).toContain("openai"); - }); - - it("renders connection selector with options", async () => { - await renderPage(); - const select = document.querySelector("select") as HTMLSelectElement; - expect(select).not.toBeNull(); - expect(select.options.length).toBeGreaterThan(1); - }); - - it("shows right-panel placeholder when no connection selected", async () => { - await renderPage(); - expect(document.body.innerHTML).toContain("unknownProviderNotice"); - }); - - it("renders save button after selecting a connection", async () => { - await renderPage(); - const select = document.querySelector("select") as HTMLSelectElement; - await act(async () => { - select.value = "conn_1"; - select.dispatchEvent(new Event("change", { bubbles: true })); - }); - expect(document.body.innerHTML).toContain("saveOverrideButton"); - }); -}); diff --git a/tests/unit/ui/same-context-filter.test.tsx b/tests/unit/ui/same-context-filter.test.tsx index ddc9aaef0b..40bcc16b57 100644 --- a/tests/unit/ui/same-context-filter.test.tsx +++ b/tests/unit/ui/same-context-filter.test.tsx @@ -6,7 +6,7 @@ * - RequestRow exports an onSameContext prop * - useTrafficFilters.setSameContext is referenced from TrafficInspectorPageClient */ -import { describe, it } from "node:test"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; @@ -17,21 +17,35 @@ const ROOT = path.resolve( __dirname, "../../../src/app/(dashboard)/dashboard/tools/traffic-inspector" ); +const SRC_ROOT = path.resolve(__dirname, "../../../src"); function read(rel: string): string { return fs.readFileSync(path.join(ROOT, rel), "utf8"); } +function readSrc(rel: string): string { + return fs.readFileSync(path.join(SRC_ROOT, rel), "utf8"); +} + describe("R5-4 same-context filter end-to-end", () => { it("useTrafficStream.applyFilter has sameContextKey branch", () => { - const src = read("hooks/useTrafficStream.ts"); + // The comparison itself now lives in the extracted, independently-testable + // matchesTrafficFilter() helper (src/lib/inspector/matchesTrafficFilter.ts) — + // useTrafficStream.applyFilter just delegates to it. + const hookSrc = read("hooks/useTrafficStream.ts"); assert.ok( - src.includes("sameContextKey") && src.includes("contextKey"), - "applyFilter should branch on sameContextKey / contextKey" + hookSrc.includes("matchesTrafficFilter"), + "applyFilter should delegate to matchesTrafficFilter" + ); + + const matcherSrc = readSrc("lib/inspector/matchesTrafficFilter.ts"); + assert.ok( + matcherSrc.includes("sameContextKey") && matcherSrc.includes("contextKey"), + "matchesTrafficFilter should branch on sameContextKey / contextKey" ); // Must actually exclude requests where contextKey differs assert.ok( - src.includes("req.contextKey !== f.sameContextKey"), + matcherSrc.includes("req.contextKey !== f.sameContextKey"), "should exclude when contextKey !== sameContextKey" ); }); diff --git a/tests/unit/ui/search-tools-compare-tab.test.tsx b/tests/unit/ui/search-tools-compare-tab.test.tsx index 3e014faf8a..3cb92d0ffc 100644 --- a/tests/unit/ui/search-tools-compare-tab.test.tsx +++ b/tests/unit/ui/search-tools-compare-tab.test.tsx @@ -270,12 +270,13 @@ describe("CompareTab", () => { await new Promise((r) => setTimeout(r, 150)); }); - // Check that the table exists and contains overlap info - const table = el.querySelector("table"); - if (table) { + // The results panel renders as a div-based side-by-side layout (not a ) — + // the overlap summary footer lives inside [data-testid='compare-results']. + const resultsPanel = el.querySelector("[data-testid='compare-results']"); + if (resultsPanel) { // URL overlap row should contain a fraction like "1/2" - const tableText = table.textContent ?? ""; - expect(tableText).toMatch(/URL overlap|\d+\/\d+/); + const panelText = resultsPanel.textContent ?? ""; + expect(panelText).toMatch(/in common|\d+\/\d+/); } else { // Loading state is still active — acceptable expect(el.querySelector("[data-testid='compare-loading']")).toBeTruthy(); diff --git a/tests/unit/ui/search-tools-scrape-tab.test.tsx b/tests/unit/ui/search-tools-scrape-tab.test.tsx index 67509d4f47..f63bac664b 100644 --- a/tests/unit/ui/search-tools-scrape-tab.test.tsx +++ b/tests/unit/ui/search-tools-scrape-tab.test.tsx @@ -112,7 +112,9 @@ describe("ScrapeTab", () => { }); const errorEl = el.querySelector("[data-testid='url-error']"); expect(errorEl).toBeTruthy(); - expect(errorEl?.textContent).toContain("URL"); + // next-intl is mocked as a key pass-through above (per repo convention), so the + // rendered text is the raw i18n key, not the translated "URL is required" copy. + expect(errorEl?.textContent).toContain("scrapeUrlRequired"); }); it("shows error for invalid URL", () => { diff --git a/tests/unit/ui/session-recorder-bar.test.tsx b/tests/unit/ui/session-recorder-bar.test.tsx index 2363617277..d4e88c48e3 100644 --- a/tests/unit/ui/session-recorder-bar.test.tsx +++ b/tests/unit/ui/session-recorder-bar.test.tsx @@ -1,7 +1,7 @@ /** * Tests for SessionRecorderBar — start/stop flow + timer logic */ -import { describe, it } from "node:test"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; function formatElapsed(s: number): string { diff --git a/tests/unit/ui/stats-tab.test.tsx b/tests/unit/ui/stats-tab.test.tsx index 3cdd100ee8..03703966e0 100644 --- a/tests/unit/ui/stats-tab.test.tsx +++ b/tests/unit/ui/stats-tab.test.tsx @@ -2,7 +2,7 @@ * Asserts that StatsTab lazy-loads StatsCharts via next/dynamic (ssr: false) * and does NOT statically import anything from "recharts". */ -import { describe, it } from "node:test"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; diff --git a/tests/unit/ui/timing-i18n.test.tsx b/tests/unit/ui/timing-i18n.test.tsx index 5d2ee14a77..e52f840c82 100644 --- a/tests/unit/ui/timing-i18n.test.tsx +++ b/tests/unit/ui/timing-i18n.test.tsx @@ -5,7 +5,7 @@ * Round-3 F-I18N translated ConversationTab/StatsTab/StatsCharts but missed * TimingTab (5 labels) and TimingWaterfall (2 labels). Round-4 closed the gap. */ -import { describe, it } from "node:test"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; diff --git a/tests/unit/ui/traffic-inspector-page.test.tsx b/tests/unit/ui/traffic-inspector-page.test.tsx index f908eab22b..67f1cbd1c0 100644 --- a/tests/unit/ui/traffic-inspector-page.test.tsx +++ b/tests/unit/ui/traffic-inspector-page.test.tsx @@ -1,7 +1,7 @@ /** * Smoke tests for Traffic Inspector page structure and constants */ -import { describe, it } from "node:test"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; describe("Traffic Inspector page smoke tests", () => { diff --git a/tests/unit/ui/use-resizable-panels.test.tsx b/tests/unit/ui/use-resizable-panels.test.tsx index 156d653738..5dee3cdcf1 100644 --- a/tests/unit/ui/use-resizable-panels.test.tsx +++ b/tests/unit/ui/use-resizable-panels.test.tsx @@ -1,7 +1,7 @@ /** * Tests for useResizablePanels — drag changes width, collapse to 48px, localStorage persistence */ -import { describe, it } from "node:test"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; const MIN_WIDTH = 280; diff --git a/tests/unit/ui/use-session-recorder.test.tsx b/tests/unit/ui/use-session-recorder.test.tsx index f4889b244a..f39277a653 100644 --- a/tests/unit/ui/use-session-recorder.test.tsx +++ b/tests/unit/ui/use-session-recorder.test.tsx @@ -4,7 +4,7 @@ * Verifies that during recording, new traffic WS events trigger * POST to /api/tools/traffic-inspector/sessions/{id}/requests. */ -import { describe, it, before, after } from "node:test"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; diff --git a/tests/unit/ui/use-system-proxy-exit-guard.test.tsx b/tests/unit/ui/use-system-proxy-exit-guard.test.tsx index af1eaeeb1b..fa6d7597a5 100644 --- a/tests/unit/ui/use-system-proxy-exit-guard.test.tsx +++ b/tests/unit/ui/use-system-proxy-exit-guard.test.tsx @@ -6,7 +6,7 @@ * This matches how use-traffic-stream.test.tsx tests hook logic (pure logic, * no React renderer needed). */ -import { describe, it, beforeEach } from "node:test"; +import { describe, it, beforeEach } from "vitest"; import assert from "node:assert/strict"; // --------------------------------------------------------------------------- diff --git a/tests/unit/ui/use-traffic-stream.test.tsx b/tests/unit/ui/use-traffic-stream.test.tsx index ce8804fa9b..39ca601e28 100644 --- a/tests/unit/ui/use-traffic-stream.test.tsx +++ b/tests/unit/ui/use-traffic-stream.test.tsx @@ -1,7 +1,7 @@ /** * Tests for useTrafficStream — WebSocket snapshot/new/update/clear + reconnect backoff */ -import { describe, it, before, after, mock } from "node:test"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; diff --git a/tests/unit/ui/use-virtual-list.test.tsx b/tests/unit/ui/use-virtual-list.test.tsx index 5ef5e689fb..bfb1bc9a53 100644 --- a/tests/unit/ui/use-virtual-list.test.tsx +++ b/tests/unit/ui/use-virtual-list.test.tsx @@ -1,7 +1,7 @@ /** * Tests for useVirtualList — virtualizes 1000+ items without rendering all */ -import { describe, it } from "node:test"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; const ESTIMATED_ROW_HEIGHT = 48; diff --git a/tests/unit/validate-release-green.test.ts b/tests/unit/validate-release-green.test.ts index bf62fd8096..7626975264 100644 --- a/tests/unit/validate-release-green.test.ts +++ b/tests/unit/validate-release-green.test.ts @@ -42,6 +42,20 @@ test("parseCognitiveCount reads the gate's count (en + pt)", () => { assert.equal(parseCognitiveCount("no number"), null); }); +test("parseCognitiveCount ignores the cyclomatic count in the combined ratchets output (#7009)", () => { + // `check:complexity-ratchets` runs ONE shared ESLint walk and prints BOTH ratchets. + // The cyclomatic "N violações" summary is emitted FIRST, so a bare `\\d+ violações` + // regex captured 2056 (cyclomatic) instead of 890 (cognitive) — a phantom drift in + // every pre-flight report. Prefer the unambiguous machine-readable `cognitiveComplexity=N`. + const combined = [ + "complexity=2056", + "cognitiveComplexity=890", + "[complexity] OK — 2056 violações (baseline 2056)", + "[cognitive-complexity] OK — 890 violações (baseline 890)", + ].join("\n"); + assert.equal(parseCognitiveCount(combined), 890); +}); + test("isDrift flags only growth past the committed baseline (down-direction ratchets)", () => { assert.equal(isDrift(3900, 3867), true); // grew → drift assert.equal(isDrift(3867, 3867), false); // equal → ok diff --git a/tests/unit/vercel-deploy-sso-protection-check.test.ts b/tests/unit/vercel-deploy-sso-protection-check.test.ts new file mode 100644 index 0000000000..d3fb945e96 --- /dev/null +++ b/tests/unit/vercel-deploy-sso-protection-check.test.ts @@ -0,0 +1,95 @@ +// Regression guard for upstream report: "Vercel Relay with Codex returns 403 +// Access denied and lacks source diagnostics". +// +// Root cause: the Vercel deploy route disables project SSO/Deployment +// Protection via a PATCH request, but fired it with `.catch(() => {})` and +// never inspected `res.ok`. If Vercel rejects or no-ops the PATCH (plan +// doesn't allow disabling protection, stale/under-scoped token, etc.), the +// relay is still saved and activated as a healthy proxy pool — later +// requests routed through it fail with an undiagnosed `403 Access denied` +// from Vercel's own deployment protection, indistinguishable from an +// upstream-provider 403. +// +// Fix: check the PATCH response and surface the failure back to the caller +// (`ssoProtectionWarning` in the JSON response) instead of silently +// swallowing it, so the UI/API consumer can diagnose the 403 source. +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { __disableSsoProtectionForTest } from "../../src/app/api/settings/proxy/vercel-deploy/route"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const ROUTE_PATH = join( + ROOT, + "src/app/api/settings/proxy/vercel-deploy/route.ts" +); + +describe("disableSsoProtection — checks the Vercel PATCH response instead of swallowing it", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("reports failure when Vercel rejects the PATCH (e.g. plan does not allow disabling protection)", async () => { + global.fetch = (async () => + new Response(JSON.stringify({ error: { message: "Forbidden" } }), { + status: 403, + })) as typeof fetch; + + const result = await __disableSsoProtectionForTest( + "https://api.vercel.com", + "proj_123", + "test-token" + ); + + assert.equal(result.ok, false, "must report ok:false on a non-2xx PATCH response"); + assert.equal(result.status, 403); + }); + + it("reports success when Vercel accepts the PATCH", async () => { + global.fetch = (async () => new Response(null, { status: 200 })) as typeof fetch; + + const result = await __disableSsoProtectionForTest( + "https://api.vercel.com", + "proj_123", + "test-token" + ); + + assert.equal(result.ok, true); + }); + + it("reports failure (not a thrown exception) when the PATCH request itself fails", async () => { + global.fetch = (async () => { + throw new Error("network down"); + }) as typeof fetch; + + const result = await __disableSsoProtectionForTest( + "https://api.vercel.com", + "proj_123", + "test-token" + ); + + assert.equal(result.ok, false); + }); +}); + +describe("vercel-deploy route — wires the SSO-protection check into the response", () => { + const src = readFileSync(ROUTE_PATH, "utf8"); + + it("no longer fires the PATCH with a silent `.catch(() => {})`", () => { + assert.ok( + !/ssoProtection:\s*null[\s\S]*?\.catch\(\s*\(\)\s*=>\s*\{\s*\}\s*\)/.test(src), + "the ssoProtection PATCH must not be silently swallowed with .catch(() => {})" + ); + }); + + it("surfaces a warning in the JSON response when disabling SSO protection failed", () => { + assert.ok( + src.includes("ssoProtectionWarning"), + "POST handler must surface ssoProtectionWarning in the response payload when the PATCH failed" + ); + }); +}); diff --git a/tests/unit/verify-published.test.ts b/tests/unit/verify-published.test.ts new file mode 100644 index 0000000000..b4a9be09df --- /dev/null +++ b/tests/unit/verify-published.test.ts @@ -0,0 +1,40 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + parseVersionArg, + buildDockerArgs, + CONTAINER_SCRIPT, +} from "../../scripts/release/verify-published.mjs"; + +// WS1.4 (v3.8.49 quality plan) — pure-function guards for the post-publish verifier +// (clean-container install of the PUBLISHED bytes + boot). The end-to-end path is +// exercised live against the registry; these pin the safety-relevant logic. + +test("parseVersionArg accepts strict semver incl. prerelease", () => { + assert.equal(parseVersionArg("3.8.48"), "3.8.48"); + assert.equal(parseVersionArg("3.9.0-rc.1"), "3.9.0-rc.1"); +}); + +test("parseVersionArg rejects shell-hostile and malformed input", () => { + for (const bad of ["", "3.8", "latest", "3.8.48; rm -rf /", "$(whoami)", "3.8.48 && x"]) { + assert.equal(parseVersionArg(bad), null, `should reject: ${bad}`); + } +}); + +test("buildDockerArgs passes the version via env, never into the script body", () => { + const args = buildDockerArgs("3.8.48"); + assert.equal(args[0], "run"); + assert.ok(args.includes("VERIFY_VERSION=3.8.48"), "version must travel as -e env"); + const script = args[args.length - 1]; + assert.equal(script, CONTAINER_SCRIPT); + assert.ok(!script.includes("3.8.48"), "script body must not embed the version (Hard Rule #13)"); + assert.ok(args.includes("node:24-slim"), "clean base image"); + assert.ok(args.includes("--rm"), "container must not linger"); +}); + +test("container script installs from the registry and polls health with a version match", () => { + assert.ok(CONTAINER_SCRIPT.includes('npm install -g "omniroute@${VERIFY_VERSION}"')); + assert.ok(CONTAINER_SCRIPT.includes("/api/monitoring/health")); + assert.ok(CONTAINER_SCRIPT.includes("body.version === want"), "must assert the served version"); + assert.ok(CONTAINER_SCRIPT.includes("WRONG VERSION"), "must fail loudly on version mismatch"); +}); diff --git a/tests/unit/web-cookie-validation-proxy-7058.test.ts b/tests/unit/web-cookie-validation-proxy-7058.test.ts new file mode 100644 index 0000000000..9c573020bc --- /dev/null +++ b/tests/unit/web-cookie-validation-proxy-7058.test.ts @@ -0,0 +1,98 @@ +// Regression test for #7058 — zai-web (and every other entry-bearing web-cookie +// provider) never honored a configured HTTP/SOCKS proxy during connection-test / +// cookie validation. +// +// Root cause: validateWebCookieProvider() probed `${baseUrl}/models` via +// directHttpsRequest(), which hardcodes `bypassProxyPatch: true` — forcing +// safeOutboundFetch to use the pre-patch native fetch and skip proxy-context/ +// env-var resolution entirely. That bypass was introduced in #3226 as a narrow, +// documented exception for a single NVIDIA NIM workaround +// (see tests/unit/proxy-bypass-scope-guard-3226.test.ts) but validateWebCookieProvider +// adopted it as its default transport from inception (#4023), silently extending the +// bypass to every web-cookie provider with a registry entry (zai-web among them). +// +// This test proves the cookie-validation probe reaches a local forward proxy +// (via a real CONNECT tunnel — the same mechanism undici uses for both HTTP and +// HTTPS targets) when one is configured via HTTP_PROXY, exactly like the +// specialty web-cookie validators (chatgpt-web, grok-web, ...) already do via +// validationRead/validationWrite. +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import net from "node:net"; + +const { validateWebCookieProvider } = await import("../../src/lib/providers/validation.ts"); +const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); +const { clearDispatcherCache } = await import("../../open-sse/utils/proxyDispatcher.ts"); + +const zaiWebEntry = REGISTRY["zai-web"] as { baseUrl?: string } | undefined; +const ORIGINAL_BASE_URL = zaiWebEntry?.baseUrl; +const ORIGINAL_HTTP_PROXY = process.env.HTTP_PROXY; + +test.after(() => { + if (zaiWebEntry && ORIGINAL_BASE_URL !== undefined) { + zaiWebEntry.baseUrl = ORIGINAL_BASE_URL; + } + if (ORIGINAL_HTTP_PROXY === undefined) { + delete process.env.HTTP_PROXY; + } else { + process.env.HTTP_PROXY = ORIGINAL_HTTP_PROXY; + } + clearDispatcherCache(); +}); + +test("zai-web cookie validation routes through the configured HTTP_PROXY (#7058)", async () => { + assert.ok(zaiWebEntry, "zai-web must have a providerRegistry entry for this test to be meaningful"); + + // Stand-in for chat.z.ai's /models probe target. + const target = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end("{}"); + }); + await new Promise((resolve) => target.listen(0, () => resolve())); + const targetPort = (target.address() as net.AddressInfo).port; + + // Minimal forward proxy that only speaks CONNECT (like a real corporate proxy) and + // always tunnels to the local target above, regardless of the requested host — this + // lets the "upstream" host be a non-resolvable placeholder without any real DNS + // dependency, while still proving the request actually reached the proxy. + let sawConnect = false; + const proxy = http.createServer((_req, res) => { + res.writeHead(501); + res.end("CONNECT only"); + }); + proxy.on("connect", (_req, socket) => { + sawConnect = true; + const upstream = net.connect(targetPort, "127.0.0.1", () => { + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + upstream.pipe(socket); + socket.pipe(upstream); + }); + upstream.on("error", () => socket.destroy()); + socket.on("error", () => upstream.destroy()); + }); + await new Promise((resolve) => proxy.listen(0, () => resolve())); + const proxyPort = (proxy.address() as net.AddressInfo).port; + + // A non-local-looking hostname: isLocalAddress()/resolveProxyForRequest() force a + // direct connection for any 127.*/localhost/LAN target, which would defeat this test. + zaiWebEntry!.baseUrl = "http://zai-web-validation-probe-7058.invalid"; + process.env.HTTP_PROXY = `http://127.0.0.1:${proxyPort}`; + clearDispatcherCache(); + + try { + const result = await validateWebCookieProvider({ provider: "zai-web", apiKey: "token=fake" }); + + assert.equal( + sawConnect, + true, + "BUG #7058: zai-web cookie validation never reached the configured HTTP_PROXY " + + "(bypassProxyPatch:true unconditionally uses the native, unpatched fetch)" + ); + assert.equal(result.valid, true, `expected a valid session, got ${JSON.stringify(result)}`); + } finally { + target.close(); + proxy.close(); + clearDispatcherCache(); + } +}); diff --git a/tsconfig.typecheck-dashboard.json b/tsconfig.typecheck-dashboard.json new file mode 100644 index 0000000000..a6cfaddf7c --- /dev/null +++ b/tsconfig.typecheck-dashboard.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "incremental": false + }, + "include": ["src/app/(dashboard)/**/*.ts", "src/app/(dashboard)/**/*.tsx"] +} diff --git a/vitest.config.ts b/vitest.config.ts index 4664608302..6f8d643da5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ test: { environment: "jsdom", globals: true, + setupFiles: ["./tests/_setup/vitestUiPolyfills.ts"], pool: "threads", maxWorkers: 20, fileParallelism: true,