diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb7601bd6c..3e1e54dbfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -688,7 +688,50 @@ jobs: - run: npm run build:cli - 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) + # `build:cli` monta dist/ mas NAO grava dist/BUILD_SHA — so `build:release` faz + # isso, chamando write-build-sha.mjs. O guard de proveniencia do #10427, dentro + # de check:pack-artifact, rejeita um artefato sem SHA (e rejeita mesmo com + # OMNIROUTE_ALLOW_CANARY_BUILD=1: o que nao da para identificar nao da para + # vouchear). Sem este passo o par build+validate deste job e estruturalmente + # incompativel e falha 100% das vezes. + - name: Stamp dist/BUILD_SHA for the provenance guard (#10427) + # O SHA TEM de vir do head da PR, nao de `git rev-parse HEAD`. Este workflow + # roda em `pull_request`, entao o checkout e o MERGE COMMIT efemero que o + # GitHub cria — um commit que nao existe em branch nenhuma e portanto nunca e + # ancestral da release. O guard de proveniencia (#10427) rejeita exatamente + # isso, e com razao: um artefato carimbado com o merge commit nao pode ser + # rastreado ate codigo que passou pelos gates. + env: + OMNIROUTE_BUILD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + export OMNIROUTE_BUILD_SHA="${OMNIROUTE_BUILD_SHA:0:7}" + node scripts/build/write-build-sha.mjs + # O guard de proveniencia checa ancestralidade contra `origin/main` por padrao. + # Esse e o ref certo na PUBLICACAO (npm-publish.yml roda em main), mas em + # `pull_request` e estruturalmente impossivel: enquanto a PR esta aberta o head + # dela NUNCA e ancestral de main — e o checkout raso nem traz `origin/main` para + # o grafo local, entao a sonda responde `false` de qualquer jeito. Resultado: o + # gate falhava 100% das vezes em PR. Pre-merge o unico invariante checavel e "o + # stamp corresponde a branch sob teste", entao apontamos o ref para o head da PR. + # Usamos `refs/pull//head` e nao `head.ref` porque aquele existe no PROPRIO + # origin mesmo quando a PR vem de um fork; `head.ref` so existe no repo do autor. + - name: Resolve the provenance ref for the pack gate (#10427) + id: provenance-ref + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + if [ -n "$PR_NUMBER" ]; then + git fetch --no-tags --depth=50 origin \ + "+refs/pull/$PR_NUMBER/head:refs/remotes/origin/pr-head" + echo "ref=origin/pr-head" >> "$GITHUB_OUTPUT" + else + git fetch --no-tags --depth=50 origin \ + "+refs/heads/$GITHUB_REF_NAME:refs/remotes/origin/$GITHUB_REF_NAME" + echo "ref=origin/$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT" + fi - run: npm run check:pack-artifact + env: + OMNIROUTE_RELEASE_REF: ${{ steps.provenance-ref.outputs.ref }} # 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). @@ -795,9 +838,23 @@ jobs: # D3 (plano mestre): a coverage é coletada NESTE mesmo run (c8/NODE_V8_COVERAGE propaga # aos filhos através do npm) — elimina a matrix Coverage Shard ×8, que re-executava a # suíte inteira só para medir o gate. Padrão usado pelo CI do próprio nodejs/node. + # Heap: os shards rodam sob instrumentacao de cobertura do V8, que retem muito + # mais memoria que a suite crua. Com o teto antigo de 4096 MB os shards passaram + # a abortar com SIGABRT (exit 134, "Ineffective mark-compacts near heap limit") + # ao redor de 4086 MB conforme o catalogo de providers cresceu no ciclo v3.8.50 — + # todos os testes passavam e o processo morria no fim, o que le como falha de + # teste sem ser. O teto vive em `test:unit:ci:shard` (package.json) e agora + # acompanha os 8192 MB ja usados pelas variantes nao-shardadas; os runners + # GitHub-hosted tem 16 GB. - name: Unit tests (shard ${{ matrix.shard }}/8) with V8 coverage env: TEST_SHARD: ${{ matrix.shard }}/8 + # NODE_OPTIONS (nao so o flag em test:unit:ci:shard) porque quem estoura o + # heap e o processo `c8` que embrulha a suite — ele agrega ~577 MB de JSON + # de cobertura bruta. Subir o teto so no node filho deixa o pai no default + # do V8 (~4 GB) e o OOM continua igual, em ~4083 MB. Mesmo padrao ja usado + # pelo job de merge de cobertura mais abaixo. + NODE_OPTIONS: --max-old-space-size=8192 run: | rm -rf coverage-shard coverage-shard-report npx c8 \ diff --git a/AGENTS.md b/AGENTS.md index 0c42ebc66b..c4d5d08e4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -718,3 +718,13 @@ The dashboard is reachable at the operator's chosen URL/port (default `http://lo - **Local VPS / shared dev environments**: ask the operator for the URL and current credentials — they live in their personal vault, NOT in this repo. > Any credential observed in a previous version of this file was a non-production demo value; treat it as compromised and do not reuse it. + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/changelog.d/features/10039-combo-lane-awareness-wave-2.md b/changelog.d/features/10039-combo-lane-awareness-wave-2.md deleted file mode 100644 index 7c8cba55ba..0000000000 --- a/changelog.d/features/10039-combo-lane-awareness-wave-2.md +++ /dev/null @@ -1,2 +0,0 @@ -- **feat(admission):** add lane-aware admission probes for combo/fusion/chaos fan-out (fail-open, queueing disabled), an env-wins `OMNIROUTE_CHAT_VIRTUAL_LANES` activation flag applied at boot, and adaptive-lane visibility in the `omniroute_get_health` MCP tool (related to #9654) -- **docs(mcp):** complete the MCP server README tool reference so the `schemas/` catalog is fully covered (agent-skills, oneproxy, web, tool-search, combo/routing, pricing and DB-health tools were previously only discoverable via `omniroute_tool_search`) diff --git a/changelog.d/features/10057-docker-aware-auto-config.md b/changelog.d/features/10057-docker-aware-auto-config.md deleted file mode 100644 index d8718c5801..0000000000 --- a/changelog.d/features/10057-docker-aware-auto-config.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(cli):** container-aware auto-config — `setup-*`, `omniroute configure`, `omniroute config set` and the CLI-tool config APIs now refuse to write into a containerised OmniRoute's ephemeral home (CLI exits `2`, API returns `422` with `containerEphemeralTarget`) and point at the host-CLI or bind-mount setup instead; `--allow-container-write` / `OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true` opt back in. Also fixes `CLI_CONFIG_HOME` so the Compose `host` profile's `/host-home` bind mounts are honoured instead of silently falling back to the container home. (#10057) diff --git a/changelog.d/features/10273-dashboard-embed-csp.md b/changelog.d/features/10273-dashboard-embed-csp.md deleted file mode 100644 index 8627e0ddbe..0000000000 --- a/changelog.d/features/10273-dashboard-embed-csp.md +++ /dev/null @@ -1 +0,0 @@ -- feat(dashboard): opt-in `DASHBOARD_ALLOW_EMBED=vscode` relaxes CSP `frame-ancestors` to `'self' vscode-webview:` and drops `X-Frame-Options` for HTML pages only, so the dashboard renders inside the VS Code Simple Browser (OmniCopilot). Default posture unchanged — API routes stay unframable (#10273) diff --git a/changelog.d/features/10303-healthz-event-loop-lag.md b/changelog.d/features/10303-healthz-event-loop-lag.md deleted file mode 100644 index 991c123021..0000000000 --- a/changelog.d/features/10303-healthz-event-loop-lag.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(resilience):** warn when `/healthz` is served under event-loop lag ≥200ms so a slow 200 is visible as sick, not healthy ([#10303](https://github.com/diegosouzapw/OmniRoute/issues/10303)) diff --git a/changelog.d/features/10316-livez-endpoint.md b/changelog.d/features/10316-livez-endpoint.md deleted file mode 100644 index 01409d7b48..0000000000 --- a/changelog.d/features/10316-livez-endpoint.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(docker):** add `GET`/`HEAD` `/livez` as a process-alive probe, distinct from `/healthz` readiness ([#10316](https://github.com/diegosouzapw/OmniRoute/issues/10316)) diff --git a/changelog.d/features/10389-cloudflare-playground.md b/changelog.d/features/10389-cloudflare-playground.md deleted file mode 100644 index fb6bd80c0a..0000000000 --- a/changelog.d/features/10389-cloudflare-playground.md +++ /dev/null @@ -1 +0,0 @@ -- feat(providers): add **Cloudflare AI Playground** as a No Auth provider (`cloudflare-playground`, alias `cfp`) — free anonymous chat over the reverse-engineered `cf_agent` WebSocket protocol (PartySocket transport, no account/API key/cookies) with GLM 5.2, Kimi K2.7 Code, DeepSeek V4 Pro, gpt-oss-120B, Llama 3.3 70B, Qwen2.5 Coder 32B and 14 more curated models. The executor drives a headless Chromium via Playwright (the WS upgrade is TLS-fingerprint-gated), translates the `cf_agent` frame stream into OpenAI SSE, and surfaces upstream rate limits (3021) as HTTP 429. Fixes #10389 diff --git a/changelog.d/features/10542-aihorde-optional-key-image-catalog.md b/changelog.d/features/10542-aihorde-optional-key-image-catalog.md deleted file mode 100644 index 4a8f67b766..0000000000 --- a/changelog.d/features/10542-aihorde-optional-key-image-catalog.md +++ /dev/null @@ -1,2 +0,0 @@ -- **feat(providers):** AI Horde accepts an optional registered API key and advertises only live image models that currently have workers ([#10542](https://github.com/diegosouzapw/OmniRoute/pull/10542)) -- **fix(providers):** AI Horde Check validates keys via `/v2/find_user` instead of the unauthenticated OpenAI models list ([#10542](https://github.com/diegosouzapw/OmniRoute/pull/10542)) diff --git a/changelog.d/features/10581-jina-complete-provider.md b/changelog.d/features/10581-jina-complete-provider.md deleted file mode 100644 index d4fc0424a3..0000000000 --- a/changelog.d/features/10581-jina-complete-provider.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(providers):** complete Jina AI as one credential pool — dashboard `jina-ai` / `jina-reader` share a token, `JINA_AI_API_KEY` is a real fallback, Test probes `GET https://api.jina.ai/v1/models` (embeddings fallback hits `jina-embeddings-v5-omni-small`), embed/rerank logs keep `connection_id`, catalog adds `jina-reranker-v3.5`, Omni v5 multimodal `{text}`/`{image}`/`{content}` docs pass through intact, and OmniRoute proxies classify / segment / `jina-search` (`s.jina.ai`). Reader stays a separate `r.jina.ai` card with an explicit label. Gemini Embedding 2 (`gemini/gemini-embedding-2`, alias `google/gemini-embedding-2`) uses dashboard `gemini` keys (or `GEMINI_API_KEY` / `GOOGLE_API_KEY` only when none exist), forwards native multimodal parts, and maps N OpenAI `input` items to N `:batchEmbedContents` vectors instead of one aggregated `:embedContent`. ([#10581](https://github.com/diegosouzapw/OmniRoute/pull/10581)) diff --git a/changelog.d/features/10587-ogg-speech-alias.md b/changelog.d/features/10587-ogg-speech-alias.md deleted file mode 100644 index 118e2a7b48..0000000000 --- a/changelog.d/features/10587-ogg-speech-alias.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(providers):** accept `response_format=ogg` on `/v1/audio/speech` as an alias for the existing Opus/Ogg encoder ([#10587](https://github.com/diegosouzapw/OmniRoute/issues/10587)) diff --git a/changelog.d/features/10617-auto-disable-banned-scope.md b/changelog.d/features/10617-auto-disable-banned-scope.md deleted file mode 100644 index e1fc1705a8..0000000000 --- a/changelog.d/features/10617-auto-disable-banned-scope.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(settings):** add `autoDisableBannedScope` so permanent-ban auto-disable can target subscription/OAuth accounts only, leaving prepaid API keys in the routing pool ([#10617](https://github.com/diegosouzapw/OmniRoute/pull/10617)) diff --git a/changelog.d/features/10662-systemd-notify.md b/changelog.d/features/10662-systemd-notify.md deleted file mode 100644 index 5a02e7df25..0000000000 --- a/changelog.d/features/10662-systemd-notify.md +++ /dev/null @@ -1 +0,0 @@ -- feat(server): emit systemd sd_notify READY/WATCHDOG/STOPPING (generated unit becomes Type=notify with WatchdogSec=180) so a frozen server process is killed and restarted by systemd instead of lingering undetected diff --git a/changelog.d/features/10668-newapi-gateway-protocols.md b/changelog.d/features/10668-newapi-gateway-protocols.md deleted file mode 100644 index 1ec6e5f0b7..0000000000 --- a/changelog.d/features/10668-newapi-gateway-protocols.md +++ /dev/null @@ -1,2 +0,0 @@ -- **feat(providers):** add the TabiToken NewAPI gateway (`tabitoken`) and teach the existing HCNSec entry (`hcnsec`) the three further protocols it actually serves. TabiToken leaves the NewAPI pricing endpoint public, so its catalog is read from the host rather than guessed: four Claude models, each reporting the Anthropic and OpenAI protocols. HCNSec shipped OpenAI-only; probing the host showed `/v1/messages`, `/v1/responses` and the Gemini `/v1beta` path all reach its token layer, so each is now declared as an alternate format — with its default format, base URL, auth scheme and regional catalog classification untouched. ([#10668](https://github.com/diegosouzapw/OmniRoute/pull/10668)) — thanks @yawar-aquil -- **feat(sse):** allow an alternate protocol to build its own upstream URL. `AlternateFormat` gained an optional `urlBuilder`, because the Gemini protocol carries the model inside the path (`{base}/{model}:generateContent`) and the existing `chatPath`/`urlSuffix` fields are constants that cannot express it. The route builder is extracted as `buildGeminiGenerateContentUrl` and shared with the native `gemini` provider so the two consumers cannot drift on the `?alt=sse` streaming suffix. ([#10668](https://github.com/diegosouzapw/OmniRoute/pull/10668)) — thanks @yawar-aquil diff --git a/changelog.d/features/10670-call-logs-error-type.md b/changelog.d/features/10670-call-logs-error-type.md deleted file mode 100644 index 1ffd94bd22..0000000000 --- a/changelog.d/features/10670-call-logs-error-type.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(call_logs):** persist the per-call error family in `call_logs.error_type` and expose a failure breakdown (`errorBreakdown`) in the usage analytics endpoint, reusing the existing production classifier ([#10670](https://github.com/diegosouzapw/OmniRoute/issues/10670)) diff --git a/changelog.d/features/10677-egress-sharing-summary.md b/changelog.d/features/10677-egress-sharing-summary.md deleted file mode 100644 index 9e1f723a0d..0000000000 --- a/changelog.d/features/10677-egress-sharing-summary.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(proxy):** the proxy-health sweep and `GET /api/settings/proxies/egress` now report an anonymous summary of egress-IP sharing — how many rotation groups share an egress IP and the largest number of accounts behind one IP — computed from persisted `proxy_logs` over a 24h window. No IPs and no account identities by default; `PROXY_LOG_INCLUDE_IPS=true` restores raw details. ([#10677](https://github.com/diegosouzapw/OmniRoute/issues/10677)) diff --git a/changelog.d/features/10697-vscode-copilot-guide.md b/changelog.d/features/10697-vscode-copilot-guide.md deleted file mode 100644 index ec788c7183..0000000000 --- a/changelog.d/features/10697-vscode-copilot-guide.md +++ /dev/null @@ -1 +0,0 @@ -- **docs(guides):** OmniRoute now serves VS Code's **native Copilot Chat model picker** through the [OmniCopilot](https://github.com/diegosouzapw/OmniCopilot) extension ([Marketplace](https://marketplace.visualstudio.com/items?itemName=diegosouzapw.omnicopilot) · [Open VSX](https://open-vsx.org/extension/diegosouzapw/omnicopilot) — Cursor, Windsurf, VSCodium, Theia…) — no Copilot subscription needed since VS Code 1.122. New [`docs/guides/VSCODE-COPILOT.md`](docs/guides/VSCODE-COPILOT.md) covers setup, how the picker collapses the `dual`-prefix catalog via `GET /v1/models?prefix=alias`, and the **build-time** `DASHBOARD_ALLOW_EMBED=vscode` flag that renders the dashboard in an editor tab ([#10697](https://github.com/diegosouzapw/OmniRoute/pull/10697)) diff --git a/changelog.d/features/10701-dockerfile-dashboard-embed-arg.md b/changelog.d/features/10701-dockerfile-dashboard-embed-arg.md deleted file mode 100644 index 552a873268..0000000000 --- a/changelog.d/features/10701-dockerfile-dashboard-embed-arg.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(docker):** `DASHBOARD_ALLOW_EMBED` is now a Docker build argument — `docker build --build-arg DASHBOARD_ALLOW_EMBED=vscode` produces an image whose dashboard renders inside the VS Code Simple Browser (OmniCopilot's `dashboardOpen: "editor"`). Previously the flag was only reachable from a source build: Docker silently drops a `--build-arg` with no matching `ARG`, so the operator got the default image and no error. Builder-stage only and empty by default — the runtime stages deliberately do not carry it, and the unframable default posture is unchanged ([#10701](https://github.com/diegosouzapw/OmniRoute/pull/10701)) diff --git a/changelog.d/features/10729-cursor-api-key-and-cli-passthrough.md b/changelog.d/features/10729-cursor-api-key-and-cli-passthrough.md deleted file mode 100644 index 96094ffff1..0000000000 --- a/changelog.d/features/10729-cursor-api-key-and-cli-passthrough.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(providers):** new `cursor-api` provider (card "Cursor API", alias `cua`): connect a Cursor user API key (`crsr_…`) and route `cursor-api/` through the existing Cursor agent executor (the key is exchanged for a 1h session token and cached), plus a `/api/cursor-cli/*` passthrough so the Cursor CLI itself runs through OmniRoute (`CURSOR_API_ENDPOINT=http:///api/cursor-cli`, `CURSOR_API_KEY=`) with every RPC attributed and logged. The IDE `cursor` provider is unchanged. (#10729) diff --git a/changelog.d/features/10771-health-root-endpoint.md b/changelog.d/features/10771-health-root-endpoint.md deleted file mode 100644 index a367bbce78..0000000000 --- a/changelog.d/features/10771-health-root-endpoint.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(api):** `GET /api/health` now answers `{ status, timestamp }` without a key. Until now the path had no route, so the management-auth boundary answered first with a 401 — indistinguishable from a wrong key or an unknown route, which left Docker HEALTHCHECKs and Kubernetes probes unable to tell "down" from "misconfigured". Kept deliberately minimal: version, uptime and memory stay behind the authenticated `/api/monitoring/health` ([#PRNUM](https://github.com/diegosouzapw/OmniRoute/pull/10771)). diff --git a/changelog.d/features/10783-task-routing-configurable-patterns.md b/changelog.d/features/10783-task-routing-configurable-patterns.md deleted file mode 100644 index e5c37c390a..0000000000 --- a/changelog.d/features/10783-task-routing-configurable-patterns.md +++ /dev/null @@ -1 +0,0 @@ -- feat(routing): make Task-Aware Smart Routing's detection patterns operator-configurable via `settings.taskRouting.patternOverrides` (`PUT /api/settings/task-routing`) — the built-in patterns are English-only, so a non-English dashboard had no recourse short of turning detection off entirely; an override now replaces the pattern list for one task type without touching the rest (#10783) diff --git a/changelog.d/features/10869-combo-patch-verb.md b/changelog.d/features/10869-combo-patch-verb.md deleted file mode 100644 index f11893d95c..0000000000 --- a/changelog.d/features/10869-combo-patch-verb.md +++ /dev/null @@ -1 +0,0 @@ -- feat(api): accept PATCH on /api/combos/[id], the verb the OpenAPI spec already documents (#10869) diff --git a/changelog.d/features/10896-glm-5.3.md b/changelog.d/features/10896-glm-5.3.md deleted file mode 100644 index 0edfc4a55b..0000000000 --- a/changelog.d/features/10896-glm-5.3.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(sse):** add GLM-5.3 support (`glm-5.3`, `glm-5.3-high`, `glm-5.3-low`) across the z.ai first-party providers, mapping the upstream `reasoning_effort` request parameter to the existing 5.2 tier UX ([#10896](https://github.com/diegosouzapw/OmniRoute/pull/10896)) — thanks @phuongddx diff --git a/changelog.d/features/10897-home-recent-requests.md b/changelog.d/features/10897-home-recent-requests.md deleted file mode 100644 index fd6bcc9abe..0000000000 --- a/changelog.d/features/10897-home-recent-requests.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(home):** add a live **Recent Requests** panel beside the home Provider Topology (polls `GET /api/usage/call-logs?excludeTests=1` every ~3s, gated by the topology appearance toggle + page visibility). `excludeTests` is now an allowlist of real provider inference (`/v1/%` or `/api/v1/%`), applied before `LIMIT`, so connection-test/model-sync/management rows can never leak into the feed ([#10897](https://github.com/diegosouzapw/OmniRoute/pull/10897), extracted from [#8450](https://github.com/diegosouzapw/OmniRoute/pull/8450)) — thanks @nguyenha935 diff --git a/changelog.d/features/10909-free-provider-rankings-reliability.md b/changelog.d/features/10909-free-provider-rankings-reliability.md deleted file mode 100644 index e5377885ef..0000000000 --- a/changelog.d/features/10909-free-provider-rankings-reliability.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(rankings):** free provider rankings now expose a `reliability` field (raw `testStatus`/`rateLimitedUntil` per connection plus a `healthy`/`degraded`/`down` state, reusing the `ProviderHealthState` vocabulary of the provider health matrix) when the configured/available filters are active — derived from already-loaded data, without touching the ranking order ([#10909](https://github.com/diegosouzapw/OmniRoute/pull/10909)) diff --git a/changelog.d/features/10920-egress-ip-lock.md b/changelog.d/features/10920-egress-ip-lock.md deleted file mode 100644 index af7308b62f..0000000000 --- a/changelog.d/features/10920-egress-ip-lock.md +++ /dev/null @@ -1,8 +0,0 @@ -- `feat(resilience)`: when an allowlisted provider (opencode family) answers - 429 classified `quota_exhausted` or `rate_limit_exceeded` and its free-tier - quota is bucketed by egress IP (#9611), every connection of that family - sharing the IP is cooled down together before the rotation tries them — one - guaranteed-failed upstream call per episode instead of N, on the combo path - as well. For the allowlisted family a 429 now cools the connection instead - of locking a single model. Exclusive allowlist, never terminal, best-effort - when the egress IP is unknown (#10920). diff --git a/changelog.d/features/10926-rankings-usage-reliability.md b/changelog.d/features/10926-rankings-usage-reliability.md deleted file mode 100644 index a79b92b4cf..0000000000 --- a/changelog.d/features/10926-rankings-usage-reliability.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(rankings):** free provider rankings can now report what each provider actually served — `reliability.usage` (requests, successes, success rate over a window) behind the opt-in `withUsage`/`usageRange` query parameters, so a provider that answers every call with an error is no longer described as healthy ([#10926](https://github.com/diegosouzapw/OmniRoute/pull/10926)) diff --git a/changelog.d/features/10987-logfare-free-provider.md b/changelog.d/features/10987-logfare-free-provider.md deleted file mode 100644 index 507a528411..0000000000 --- a/changelog.d/features/10987-logfare-free-provider.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(providers):** add Logfare as a free OpenAI-compatible provider — dashboard card with a Free badge and request-logging disclosure (every prompt/completion is logged for research; opt out at logfare.ai/consent), live model discovery from `https://logfare.ai/v1/models` (20 models, 11 chat-capable: kimi-k3, deepseek-v4-pro, glm-5.2, gpt-5.6-luna, minimax-m3…), full chat/streaming through the existing OpenAI-compatible path, the real Logfare logo on the card, and a listing in the free-tiers guide. ([#10987](https://github.com/diegosouzapw/OmniRoute/pull/10987)) diff --git a/changelog.d/features/11104-operator-error-rules.md b/changelog.d/features/11104-operator-error-rules.md deleted file mode 100644 index f31e78c01f..0000000000 --- a/changelog.d/features/11104-operator-error-rules.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(providers):** let operators declare per-provider error rules through `settings.providerErrorRules` instead of patching the catalog — an operator-supplied rule for a provider is consulted before the built-in `providerRuleRegistry`, receives the raw error text, and has its declared scope/cooldown/reason actually honored end to end, for any provider (declaring the rule is the opt-in — no extra allowlist entry needed). Matches are plain case-insensitive substrings (never RegExp) and bounded to 50 rules to keep the hot path safe ([#11104](https://github.com/diegosouzapw/OmniRoute/pull/11104)) diff --git a/changelog.d/features/11134-configurable-max-global-attempts.md b/changelog.d/features/11134-configurable-max-global-attempts.md deleted file mode 100644 index 2a5605061b..0000000000 --- a/changelog.d/features/11134-configurable-max-global-attempts.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(combo):** the shared per-request combo attempt budget is now operator-configurable via `maxGlobalAttempts` (combo config / `comboDefaults` cascade), instead of the hardcoded 30. Lower it to fail fast on a dead target pool, raise it for large combos; clamped to `[1, 200]` so an unbounded budget can never cause runaway background requests ([#11134](https://github.com/diegosouzapw/OmniRoute/issues/11134)) diff --git a/changelog.d/features/11190-usage-command-json.md b/changelog.d/features/11190-usage-command-json.md deleted file mode 100644 index d7655f04c5..0000000000 --- a/changelog.d/features/11190-usage-command-json.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(api):** `/api/usage/om-usage` gains a structured form — `?format=json` returns the key's own usage as `ApiKeyUsageLimitStatus` + `UsageSnapshot` instead of `text/plain`. This is the surface a UI (the OmniCopilot panel) consumes to show a key holder their daily/weekly spend and quota reset. The route is self-service (the caller's own key, gated by `allowUsageCommand`), not the management surface; refusals come back as a discriminated `{ "allowed": false, "error": … }` so a UI can tell "not allowed" apart from "allowed but nothing cached yet". The endpoint was previously undocumented in `API_REFERENCE.md`; it now has a section ([#11190](https://github.com/diegosouzapw/OmniRoute/pull/11190)) diff --git a/changelog.d/features/11192-usage-command-providers-array.md b/changelog.d/features/11192-usage-command-providers-array.md deleted file mode 100644 index b7ef421109..0000000000 --- a/changelog.d/features/11192-usage-command-providers-array.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(api):** `/api/usage/om-usage?format=json` now returns `providers[]` — every connection's quota snapshot, not just the single selected one — so a panel can render Codex / Claude / OpenCode side by side. The collector already gathered all of them; the single-pick `provider` field (kept) is a terminal presentation choice. Closes the per-connection gap from OmniCopilot #8 ([#11192](https://github.com/diegosouzapw/OmniRoute/pull/11192)) diff --git a/changelog.d/features/11251-connection-max-wait-ms-override.md b/changelog.d/features/11251-connection-max-wait-ms-override.md deleted file mode 100644 index 25ac42dd98..0000000000 --- a/changelog.d/features/11251-connection-max-wait-ms-override.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(providers):** allow overriding the rate-limit queue wait timeout (`maxWaitMs`) per connection, alongside the existing `rpm`/`tpm`/`tpd`/`minTime`/`maxConcurrent` overrides — a single slow provider no longer has to lower the global wait budget for every other provider (#11251) diff --git a/changelog.d/features/11282-first-run-readiness-card.md b/changelog.d/features/11282-first-run-readiness-card.md deleted file mode 100644 index e2899a54e5..0000000000 --- a/changelog.d/features/11282-first-run-readiness-card.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(dashboard):** replace the hard Home → onboarding redirect with a dismissable first-run readiness card so returning users can stay on Home while new users still get a clear 4-step path ([#11282](https://github.com/diegosouzapw/OmniRoute/pull/11282)) diff --git a/changelog.d/features/11283-traffic-inspector-purpose-header.md b/changelog.d/features/11283-traffic-inspector-purpose-header.md deleted file mode 100644 index 4faaf5bc57..0000000000 --- a/changelog.d/features/11283-traffic-inspector-purpose-header.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(dashboard):** lead Traffic Inspector with a purpose-first header that separates "what happened" from "how it happened", so beginners can read request outcomes without drowning in protocol detail ([#11283](https://github.com/diegosouzapw/OmniRoute/pull/11283)) diff --git a/changelog.d/features/11286-essentials-sidebar-preset.md b/changelog.d/features/11286-essentials-sidebar-preset.md deleted file mode 100644 index f05152001a..0000000000 --- a/changelog.d/features/11286-essentials-sidebar-preset.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(dashboard):** add an Essentials sidebar preset that shows only the beginner core path (Home → Endpoints → API Keys → Providers → Health → Settings) while keeping Advanced tools reachable via Command Palette search ([#11286](https://github.com/diegosouzapw/OmniRoute/pull/11286)) diff --git a/changelog.d/features/8443-credential-health-per-connection-interval.md b/changelog.d/features/8443-credential-health-per-connection-interval.md deleted file mode 100644 index 1fd1d3e5a2..0000000000 --- a/changelog.d/features/8443-credential-health-per-connection-interval.md +++ /dev/null @@ -1,2 +0,0 @@ -- **feat(credential-health):** pace the credential health sweep per connection via `provider_connections.healthCheckInterval` (minutes, 0 = never), with `CREDENTIAL_HEALTH_CHECK_INTERVAL` as the global default ([#8443](https://github.com/diegosouzapw/OmniRoute/issues/8443)) -- **behavior change:** `healthCheckInterval` is a shared column — it paces both the OAuth token refresh and the credential health sweep, and `0` disables both. The connection editor defaults it to 60, so configured OAuth connections are now credential-checked at 60min instead of the previous ~10min (aligned with the probe-volume goal of #8443) diff --git a/changelog.d/features/9085-poolside-laguna-model-ids.md b/changelog.d/features/9085-poolside-laguna-model-ids.md deleted file mode 100644 index ed4c0229db..0000000000 --- a/changelog.d/features/9085-poolside-laguna-model-ids.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(providers):** publish Poolside's Laguna Preview catalog statically — `poolside/laguna-xs-2.1` and `poolside/laguna-s-2.1` (262144 context, 32768 max completion, tools + reasoning, text-only), so the models are routable and visible before a key is configured instead of only after live discovery. Pins the catalog form of the XS id against the `laguna-xs.2` variant carried by third-party listings. ([#9085](https://github.com/diegosouzapw/OmniRoute/issues/9085)) diff --git a/changelog.d/features/9760-video-bridge.md b/changelog.d/features/9760-video-bridge.md deleted file mode 100644 index cc2ceca74a..0000000000 --- a/changelog.d/features/9760-video-bridge.md +++ /dev/null @@ -1 +0,0 @@ -- feat(modality-bridge): bridge Chat and Responses video parts through a strict trusted-loopback, quota-bounded FFmpeg broker; enforce HTTPS redirects/SSRF plus format, protocol, stream, pixel, frame, 50 MiB broker/remote, 36 MiB inline, and 120-second limits; propagate caller aborts; preserve the actual successful fallback model through cache/meta/headers; expose sampled latency and honest success telemetry; and ship the localized Video settings UI (#9760) diff --git a/changelog.d/features/9830-radar-local-model-state.md b/changelog.d/features/9830-radar-local-model-state.md deleted file mode 100644 index a6df34c7e7..0000000000 --- a/changelog.d/features/9830-radar-local-model-state.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(radar):** Persist local model display-name/enabled overrides and hide/restore tombstones, with authenticated catalog controls and feed safety precedence ([#9830](https://github.com/diegosouzapw/OmniRoute/pull/9830)) diff --git a/changelog.d/features/9836-radar-guided-combos.md b/changelog.d/features/9836-radar-guided-combos.md deleted file mode 100644 index c813289b35..0000000000 --- a/changelog.d/features/9836-radar-guided-combos.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(radar):** add curated-family combo suggestions, a guided combo page, and the read-only Radar MCP catalog tool ([#9836](https://github.com/diegosouzapw/OmniRoute/pull/9836)) diff --git a/changelog.d/features/9912-radar-supporter-offers.md b/changelog.d/features/9912-radar-supporter-offers.md deleted file mode 100644 index a394c737e9..0000000000 --- a/changelog.d/features/9912-radar-supporter-offers.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(radar):** add a signed live offers feed and supporter offers dashboard ([#9912](https://github.com/diegosouzapw/OmniRoute/pull/9912)) diff --git a/changelog.d/features/9923-radar-intel.md b/changelog.d/features/9923-radar-intel.md deleted file mode 100644 index 033b3fc4b3..0000000000 --- a/changelog.d/features/9923-radar-intel.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(radar):** add signed Intel insights, supporter recognition, and local Radar CLI commands ([#9923](https://github.com/diegosouzapw/OmniRoute/pull/9923)) diff --git a/changelog.d/features/9926-radar-launch-news.md b/changelog.d/features/9926-radar-launch-news.md deleted file mode 100644 index 9ec56bebd0..0000000000 --- a/changelog.d/features/9926-radar-launch-news.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(radar):** add a localized public news feed and dismissible dashboard launch banner, with the Radar announcement staged inactive for a separately authorized launch ([#9926](https://github.com/diegosouzapw/OmniRoute/pull/9926)) diff --git a/changelog.d/features/command-code-reasoning-efforts.md b/changelog.d/features/command-code-reasoning-efforts.md deleted file mode 100644 index 3e9b172204..0000000000 --- a/changelog.d/features/command-code-reasoning-efforts.md +++ /dev/null @@ -1 +0,0 @@ -- feat(command-code): advertise low/medium/high/xhigh/max reasoning-effort suffixes for reasoning-capable models in the catalog and Combo Builder, with request-time resolution to reasoning_effort diff --git a/changelog.d/features/crofai-reasoning-efforts.md b/changelog.d/features/crofai-reasoning-efforts.md deleted file mode 100644 index 6a84017aba..0000000000 --- a/changelog.d/features/crofai-reasoning-efforts.md +++ /dev/null @@ -1 +0,0 @@ -- feat(crof): advertise reasoning-effort tiers (none/low/medium/high/max) for live-discovered and seed models, so the catalog, Playground, and Combo Builder surface - aliases and requests resolve max upstream diff --git a/changelog.d/features/cursor-agent-image-provider.md b/changelog.d/features/cursor-agent-image-provider.md deleted file mode 100644 index 84646dc44e..0000000000 --- a/changelog.d/features/cursor-agent-image-provider.md +++ /dev/null @@ -1 +0,0 @@ -- feat(sse): add Cursor plan image generation via Agent CLI (`IMAGE_PROVIDERS.cursor`, format `cursor-agent-image`), reusing the chat Cursor OAuth connection diff --git a/changelog.d/features/disable-context-window-checks.md b/changelog.d/features/disable-context-window-checks.md deleted file mode 100644 index 1cdd3cc0a8..0000000000 --- a/changelog.d/features/disable-context-window-checks.md +++ /dev/null @@ -1 +0,0 @@ -- feat(routing): add the default-off `DISABLE_CONTEXT_WINDOW_CHECKS` feature flag to let operators bypass OmniRoute's local context-window and max-input-token check for direct single-model requests, leaving upstream limits, prompt compression, and output-token caps intact. diff --git a/changelog.d/features/effort-tiers-loop-learned-sets.md b/changelog.d/features/effort-tiers-loop-learned-sets.md deleted file mode 100644 index 29b5b10ec6..0000000000 --- a/changelog.d/features/effort-tiers-loop-learned-sets.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(catalog):** surface runtime-learned `reasoning_effort` tiers in `/v1/models` `capabilities.effort_tiers` (learned set replaces synced metadata when present), map them to OpenCode `ModelV2.variants` in the OmniRoute plugin, and align dispatch `-` suffix validation to the effective (learned ?? synced) set — so the UI offers exactly the tiers the upstream accepts (e.g. `{low, high, max}` for `oc/x-preview-f-free`) and each advertised variant completes. Excludes codex/glm/kimi, which keep their own dedicated `-{effort}` suffix mechanism and never gain `effort_tiers` from this path (related to #7694, builds on #11232) diff --git a/changelog.d/features/kimi-coding-extra-usage.md b/changelog.d/features/kimi-coding-extra-usage.md deleted file mode 100644 index 766ec1020c..0000000000 --- a/changelog.d/features/kimi-coding-extra-usage.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(usage):** show Kimi Coding's fixed-order Code 5-hour/7-day quota windows plus Extra Usage status, balance, monthly spend/limit, and the official Additional Credits link on Dashboard → Quota cards. diff --git a/changelog.d/features/m365-copilot-tool-calls.md b/changelog.d/features/m365-copilot-tool-calls.md deleted file mode 100644 index bfafe08033..0000000000 --- a/changelog.d/features/m365-copilot-tool-calls.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(providers):** copilot-m365-web now supports OpenAI tool calling — a router planning turn asks the substrate model (as a tool-selection assistant emitting `CALL_TOOL: name({...})` / `NO_TOOL_NEEDED` text, which bypasses its plugin-registry refusal) and validated decisions surface as `tool_calls` with `finish_reason: "tool_calls"` in both stream and non-stream modes; also flattens the full message history (assistant `tool_calls` + compacted tool results) so multi-turn agent loops keep context, replies to SignalR `type:6` keepalives, surfaces `type:3` error frames instead of a silent empty `stop`, and suppresses `writeAtCursor` text from tool-progress frames diff --git a/changelog.d/features/multimodal-embeddings-alias.md b/changelog.d/features/multimodal-embeddings-alias.md deleted file mode 100644 index b69c53ecb5..0000000000 --- a/changelog.d/features/multimodal-embeddings-alias.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(api):** add `GET`/`POST` `/v1/multimodal-embeddings` as an alias of `/v1/embeddings` so Jina-compatible clients do not receive HTTP 404 `unknown_route` — thanks @RaviTharuma diff --git a/changelog.d/features/opencode-go-muse-spark-efforts.md b/changelog.d/features/opencode-go-muse-spark-efforts.md deleted file mode 100644 index 25da8482a9..0000000000 --- a/changelog.d/features/opencode-go-muse-spark-efforts.md +++ /dev/null @@ -1 +0,0 @@ -- feat(opencode-go): expose Muse Spark 1.2 Contributor reasoning-effort aliases (minimal/low/medium/high/xhigh) in the Combo Builder diff --git a/changelog.d/features/per-connection-upstream-timeout.md b/changelog.d/features/per-connection-upstream-timeout.md deleted file mode 100644 index a5987ed485..0000000000 --- a/changelog.d/features/per-connection-upstream-timeout.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(providers):** restore the operator-owned upstream timeout tier per connection via `providerSpecificData.timeoutMs` (preempts the maintainer-only model/provider registry tiers and the global `FETCH_TIMEOUT_MS`), and make the combo per-target timeout ceiling follow the selected connection \ No newline at end of file diff --git a/changelog.d/features/unreleased-detached-cli-tray.md b/changelog.d/features/unreleased-detached-cli-tray.md deleted file mode 100644 index e556a57dc0..0000000000 --- a/changelog.d/features/unreleased-detached-cli-tray.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(cli):** run `omniroute serve --tray` as a detached desktop process after server and tray readiness, with graphical login auto-start support. diff --git a/changelog.d/features/unreleased-exclusive-managed-session-leases.md b/changelog.d/features/unreleased-exclusive-managed-session-leases.md deleted file mode 100644 index 9db23724ef..0000000000 --- a/changelog.d/features/unreleased-exclusive-managed-session-leases.md +++ /dev/null @@ -1 +0,0 @@ -- **feat(routing):** add client-, provider-, and model-neutral exclusive managed session connection leases with API-key-bound generation fencing, durable SQLite ownership, explicit allowlist policy, and bounded 429 capacity retry semantics. diff --git a/changelog.d/fixes/10017-sse-control-lines-leak-openai-clients.md b/changelog.d/fixes/10017-sse-control-lines-leak-openai-clients.md deleted file mode 100644 index 3c129442b4..0000000000 --- a/changelog.d/fixes/10017-sse-control-lines-leak-openai-clients.md +++ /dev/null @@ -1 +0,0 @@ -- **Passthrough streaming:** stop leaking upstream SSE control lines (`id:`/`event:`/`retry:`/`:` comments) to plain OpenAI Chat-Completions-format clients, while preserving `event:` framing for OpenAI Responses API and Claude Messages API passthrough ([#10017](https://github.com/diegosouzapw/OmniRoute/issues/10017)). diff --git a/changelog.d/fixes/10028-windows-instrumentation-hook.md b/changelog.d/fixes/10028-windows-instrumentation-hook.md deleted file mode 100644 index 9879e2f3f3..0000000000 --- a/changelog.d/fixes/10028-windows-instrumentation-hook.md +++ /dev/null @@ -1 +0,0 @@ -- fix(cli): stop diagnosing every Next.js instrumentation-hook failure as the Android/Termux cache bug — only the Android "Unsupported platform: android" signal now triggers the Android hint, so a win32/desktop instrumentation error surfaces its real cause instead of a useless `mkdir -p ~/.cache` (#10028) \ No newline at end of file diff --git a/changelog.d/fixes/10060-build-sqlite-native-addon-guard.md b/changelog.d/fixes/10060-build-sqlite-native-addon-guard.md deleted file mode 100644 index b803a14cf8..0000000000 --- a/changelog.d/fixes/10060-build-sqlite-native-addon-guard.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(build):** stop the native `better-sqlite3` addon from loading during the Next.js production build (#10060). Its `Statement` destructor aborts with `SIGABRT` when a build worker thread exits (assertion in `node::RemoveEnvironmentCleanupHook`, `env == nullptr`), which can leave the build with no standalone bundle. Every DB entry point now keys off a reliable `OMNIROUTE_BUILDING=1` signal (set by `build-next-isolated.mjs` and inherited by every spawned build worker, because Next.js workers sometimes drop `NEXT_PHASE`): `getDbInstance()` returns a no-op SQLite stub during build, `driverFactory` skips the native driver and falls through to `node:sqlite`, and the `codegraph`/`kiro-import` lazy loaders fail closed. A build-time `better-sqlite3` alias to a stub (`next.config.mjs`, turbopack) backs this up without changing runtime behaviour (the real package is still `require()`d natively via `serverExternalPackages`). Also raises the default build heap 4096→6144 MB and caps Next build worker pools (`CIRCLE_NODE_TOTAL=8`) to avoid the many-core page-data-collection SIGSEGV, and adds `.gitattributes` (`*.sh text eol=lf`) so kernel-exec'd shell scripts never ship with CRLF shebangs. Deliberately does NOT downgrade the Node base image: per the maintainer's review on #10060, `release/v3.8.50` moved to `node:26-trixie-slim` through several considered commits, so the `OMNIROUTE_BUILDING` guard is re-derived against the current base rather than reverting the FROM line; the npm pin and binary-hide dance from the original PR are dropped because our build already rebuilds `better-sqlite3` deterministically via `node-gyp` and floats `npm@latest` for the CVE overlay. diff --git a/changelog.d/fixes/10071-g4f-space-anonymous-tier-proof-of-work.md b/changelog.d/fixes/10071-g4f-space-anonymous-tier-proof-of-work.md deleted file mode 100644 index 47f8de84fd..0000000000 --- a/changelog.d/fixes/10071-g4f-space-anonymous-tier-proof-of-work.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** the five g4f.space sub-providers (Groq, Gemini, Pollinations, Ollama, NVIDIA) no longer advertise a free tier — a keyless `POST /v1/chat/completions` now returns `402 insufficient_credits` behind a proof-of-work "cake" wall (re-verified live 2026-08-22), so `hasFree` is `false` and the notes point at `g4f.dev/members.html`. The gateway still works with a member key, so its registry wiring and `authType: "optional"` are unchanged ([#10071](https://github.com/diegosouzapw/OmniRoute/issues/10071)) — thanks @chirag127 diff --git a/changelog.d/fixes/10077-chatgpt-web-max-thinking-effort.md b/changelog.d/fixes/10077-chatgpt-web-max-thinking-effort.md deleted file mode 100644 index bf603f72d5..0000000000 --- a/changelog.d/fixes/10077-chatgpt-web-max-thinking-effort.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(chatgpt-web):** Preserve native `max` thinking effort through ChatGPT Web routing ([#10077](https://github.com/diegosouzapw/OmniRoute/pull/10077)) — thanks @zannen7 diff --git a/changelog.d/fixes/10078-agentrouter-quota-missing-dashboard.md b/changelog.d/fixes/10078-agentrouter-quota-missing-dashboard.md deleted file mode 100644 index b67fcc0f62..0000000000 --- a/changelog.d/fixes/10078-agentrouter-quota-missing-dashboard.md +++ /dev/null @@ -1,2 +0,0 @@ -- Fix: wire AgentRouter's existing console balance fetcher into the Dashboard Quota UI (visibility gate + provider-limits data path + background sync) so its wallet balance renders instead of falling back to "Usage API not implemented" (#10078) -- Fix: AgentRouter's dollar balance now renders as a currency-formatted "$X.XX" credits row in the Dashboard Quota UI instead of a bare percentage, and an exhausted wallet always shows exactly $0.00 (#10078) \ No newline at end of file diff --git a/changelog.d/fixes/10085-compatible-chat-credential-mismatch.md b/changelog.d/fixes/10085-compatible-chat-credential-mismatch.md deleted file mode 100644 index 773d4ed3cb..0000000000 --- a/changelog.d/fixes/10085-compatible-chat-credential-mismatch.md +++ /dev/null @@ -1 +0,0 @@ -- fix(sse): bridge generic openai-compatible/anthropic-compatible provider type ids to their concrete uuid node id in credential lookup (#10085) diff --git a/changelog.d/fixes/10095-antigravity-multiaccount-quota-false-exhaustion.md b/changelog.d/fixes/10095-antigravity-multiaccount-quota-false-exhaustion.md deleted file mode 100644 index 579005e943..0000000000 --- a/changelog.d/fixes/10095-antigravity-multiaccount-quota-false-exhaustion.md +++ /dev/null @@ -1 +0,0 @@ -- fix(domain): stop treating an unreported Antigravity quota fraction (`fractionReported:false`) as 0% remaining in `quotaCache.ts`, which was falsely marking every fresh/newly-connected account as exhausted and blocking multi-account rotation (#10095) diff --git a/changelog.d/fixes/10096-kimi-coding-apikey-save.md b/changelog.d/fixes/10096-kimi-coding-apikey-save.md deleted file mode 100644 index 2b5f1bb8b6..0000000000 --- a/changelog.d/fixes/10096-kimi-coding-apikey-save.md +++ /dev/null @@ -1 +0,0 @@ -- fix(dashboard): remap unified Kimi Code card API-key save to the admitted `kimi-coding-apikey` connection id, fixing 400 "Invalid provider" on Save (#10096) diff --git a/changelog.d/fixes/10104-antigravity-trailing-model-turn.md b/changelog.d/fixes/10104-antigravity-trailing-model-turn.md deleted file mode 100644 index 80af15279f..0000000000 --- a/changelog.d/fixes/10104-antigravity-trailing-model-turn.md +++ /dev/null @@ -1 +0,0 @@ -- fix(antigravity): strip trailing model turn for native Gemini requests too, not just Claude (#10104) diff --git a/changelog.d/fixes/10111-adaptive-admission-latency-collapse.md b/changelog.d/fixes/10111-adaptive-admission-latency-collapse.md deleted file mode 100644 index 1b806d53e6..0000000000 --- a/changelog.d/fixes/10111-adaptive-admission-latency-collapse.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(admission):** stop the adaptive latency-gradient collapse from permanently locking out ordinary requests — individually valid requests now make solo progress when the system is idle and normal pressure, and the collapsed limit actively recovers on sustained idle windows instead of being stuck; the critical-pressure fuse still wins over solo progress (#10111) \ No newline at end of file diff --git a/changelog.d/fixes/10119-claude-haiku-45-capability-flags.md b/changelog.d/fixes/10119-claude-haiku-45-capability-flags.md deleted file mode 100644 index 799486ffb0..0000000000 --- a/changelog.d/fixes/10119-claude-haiku-45-capability-flags.md +++ /dev/null @@ -1 +0,0 @@ -- fix(sse): downgrade client-supplied `thinking:{type:"adaptive"}` to `enabled` and gate the `context-1m-2025-08-07` beta on model eligibility when a combo/fallback re-routes a request to a non-adaptive/non-1M model like claude-haiku-4-5 (avoids "adaptive thinking is not supported on this model" and "long context beta is not yet available" 400s, #10119) \ No newline at end of file diff --git a/changelog.d/fixes/10123-async-call-log-artifacts.md b/changelog.d/fixes/10123-async-call-log-artifacts.md deleted file mode 100644 index 60afcde1cc..0000000000 --- a/changelog.d/fixes/10123-async-call-log-artifacts.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(logging):** move call-log artifact serialization and filesystem writes to a bounded singleton worker to keep request handling responsive (#10123) diff --git a/changelog.d/fixes/10125-incremental-call-log-rotation.md b/changelog.d/fixes/10125-incremental-call-log-rotation.md deleted file mode 100644 index 50657fb19e..0000000000 --- a/changelog.d/fixes/10125-incremental-call-log-rotation.md +++ /dev/null @@ -1 +0,0 @@ -- **perf(logging):** bound each scheduled call-log rotation pass to incremental database and filesystem work (#10125) diff --git a/changelog.d/fixes/10127-early-sse-heartbeat.md b/changelog.d/fixes/10127-early-sse-heartbeat.md deleted file mode 100644 index 4ada9f43a4..0000000000 --- a/changelog.d/fixes/10127-early-sse-heartbeat.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(streaming):** start early SSE heartbeats when Responses or Messages requests opt into streaming through the request body (#10127) diff --git a/changelog.d/fixes/10136-combo-scoped-session-stickiness.md b/changelog.d/fixes/10136-combo-scoped-session-stickiness.md deleted file mode 100644 index 6cab4a2a7b..0000000000 --- a/changelog.d/fixes/10136-combo-scoped-session-stickiness.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(combo):** scope session-stickiness bindings to their owning Combo so identical first messages cannot carry a successful target into another priority chain and bypass its configured order (fixes #10136) diff --git a/changelog.d/fixes/10139-thinking-output-cap-provider-scope.md b/changelog.d/fixes/10139-thinking-output-cap-provider-scope.md deleted file mode 100644 index 6fc97e467a..0000000000 --- a/changelog.d/fixes/10139-thinking-output-cap-provider-scope.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(translator):** resolve the Claude thinking output cap with the routed provider so a provider-scoped-only `max_output_tokens` override is no longer invisible to `fitThinkingToMaxTokens()`, which previously let the synthesized `max_tokens` (caller room + thinking budget) go out unbounded and 400 upstream ([#10139](https://github.com/diegosouzapw/OmniRoute/issues/10139)) diff --git a/changelog.d/fixes/10140-conol-web-import-depth.md b/changelog.d/fixes/10140-conol-web-import-depth.md deleted file mode 100644 index 9d920035b6..0000000000 --- a/changelog.d/fixes/10140-conol-web-import-depth.md +++ /dev/null @@ -1,3 +0,0 @@ -- fix(providers): correct the conol-web registry fallback-models import depth, which pointed at a - non-existent `open-sse/config/services/` and made any suite loading the provider registry fail to - resolve (#10140) diff --git a/changelog.d/fixes/10144-claude-import-cli-user-id.md b/changelog.d/fixes/10144-claude-import-cli-user-id.md deleted file mode 100644 index 0c892de04b..0000000000 --- a/changelog.d/fixes/10144-claude-import-cli-user-id.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(oauth):** Claude connections created via `claude-auth/import` now send required CLI headers on the bootstrap identity call and persist a `cliUserID` device identity, fixing intermittent "Third-party apps now draw from your extra usage" 400s on otherwise valid imported subscription tokens ([#10144](https://github.com/diegosouzapw/OmniRoute/pull/10144), fixes [#10143](https://github.com/diegosouzapw/OmniRoute/issues/10143)) diff --git a/changelog.d/fixes/10156-responses-commentary-completed-snapshot.md b/changelog.d/fixes/10156-responses-commentary-completed-snapshot.md deleted file mode 100644 index 7a976ab85d..0000000000 --- a/changelog.d/fixes/10156-responses-commentary-completed-snapshot.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(sse):** Responses-passthrough `response.completed` snapshots now drop `phase:"commentary"` items the same way live SSE frames already do, so the terminal `response.output` array no longer echoes internal commentary text that was already suppressed from the stream (#10156). diff --git a/changelog.d/fixes/10158-local-proxy-subscription.md b/changelog.d/fixes/10158-local-proxy-subscription.md deleted file mode 100644 index 76194c6d49..0000000000 --- a/changelog.d/fixes/10158-local-proxy-subscription.md +++ /dev/null @@ -1 +0,0 @@ -- fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs (local-first, cloud-metadata still blocked) (#10158) diff --git a/changelog.d/fixes/10162-approximate-combo-context-advisory.md b/changelog.d/fixes/10162-approximate-combo-context-advisory.md deleted file mode 100644 index 3c1bc703a0..0000000000 --- a/changelog.d/fixes/10162-approximate-combo-context-advisory.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(routing):** keep approximate Combo context estimates advisory so requests reach concrete targets instead of returning a pre-dispatch 400 ([#10162](https://github.com/diegosouzapw/OmniRoute/pull/10162)) — thanks @xz-dev diff --git a/changelog.d/fixes/10169-thinking-budget-docs-i18n.md b/changelog.d/fixes/10169-thinking-budget-docs-i18n.md deleted file mode 100644 index 131288a49a..0000000000 --- a/changelog.d/fixes/10169-thinking-budget-docs-i18n.md +++ /dev/null @@ -1 +0,0 @@ -- **docs(settings):** document Thinking Budget modes (passthrough vs auto-strip); fix dashboard i18n key collision that showed Auto Combo routing copy on the thinking tab; clarify independence from compression/cache ([#10169](https://github.com/diegosouzapw/OmniRoute/pull/10169)) diff --git a/changelog.d/fixes/10171-instrumentation-hook-boot-fatal-log.md b/changelog.d/fixes/10171-instrumentation-hook-boot-fatal-log.md deleted file mode 100644 index 3f2c0fb028..0000000000 --- a/changelog.d/fixes/10171-instrumentation-hook-boot-fatal-log.md +++ /dev/null @@ -1 +0,0 @@ -- fix(cli): guarantee a non-empty `[STARTUP] Fatal:` log line for any instrumentation-hook boot throw, not just DB-driver init failures (#10171) diff --git a/changelog.d/fixes/10183-10268-admission-heap-conditional-shed.md b/changelog.d/fixes/10183-10268-admission-heap-conditional-shed.md deleted file mode 100644 index f99bba5035..0000000000 --- a/changelog.d/fixes/10183-10268-admission-heap-conditional-shed.md +++ /dev/null @@ -1 +0,0 @@ -- fix(sse): gate structural chat admission shedding on real heap pressure instead of unconditional capacity, with a bounded headroom budget so a healthy heap can no longer bypass admission control indefinitely (#10183, #10268) diff --git a/changelog.d/fixes/10202-responses-vision-bridge.md b/changelog.d/fixes/10202-responses-vision-bridge.md deleted file mode 100644 index cb5ff02038..0000000000 --- a/changelog.d/fixes/10202-responses-vision-bridge.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(guardrails):** Vision Bridge handles OpenAI Responses `input`/`input_image` requests before combo vision filtering ([#10202](https://github.com/diegosouzapw/OmniRoute/pull/10202)) — thanks @Zartharas diff --git a/changelog.d/fixes/10215-cursor-kv-after-text-toolcalls.md b/changelog.d/fixes/10215-cursor-kv-after-text-toolcalls.md deleted file mode 100644 index db0ea1df9a..0000000000 --- a/changelog.d/fixes/10215-cursor-kv-after-text-toolcalls.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(cursor):** Stop truncating pending tool calls on non-composer models when a KV checkpoint arrives after text but before the `exec_mcp` frame — the KV short-circuit is now gated to the composer family where it was verified ([#10215](https://github.com/diegosouzapw/OmniRoute/issues/10215)). \ No newline at end of file diff --git a/changelog.d/fixes/10223-deepseek-responses-sse-cjk-deltas.md b/changelog.d/fixes/10223-deepseek-responses-sse-cjk-deltas.md deleted file mode 100644 index 8f3c19bb20..0000000000 --- a/changelog.d/fixes/10223-deepseek-responses-sse-cjk-deltas.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(responses):** repair corrupted SSE deltas for non-ASCII streams by keeping a single stream-aware `TextDecoder` (`{ stream: true }`) across `transform()` calls instead of recreating it per chunk and decoding without the `stream` flag. When a multi-byte UTF-8 character (CJK/emoji) was split across two TCP chunks — common in Chinese streaming text — the per-chunk decoder truncated it to `U+FFFD`, corrupting every delta while the rebuilt `*.done` snapshot stayed internally identical ([#10223](https://github.com/diegosouzapw/OmniRoute/issues/10223)) \ No newline at end of file diff --git a/changelog.d/fixes/10225-combo-context-overflow-before-compression.md b/changelog.d/fixes/10225-combo-context-overflow-before-compression.md deleted file mode 100644 index 0a678180af..0000000000 --- a/changelog.d/fixes/10225-combo-context-overflow-before-compression.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) \ No newline at end of file diff --git a/changelog.d/fixes/10228-provider-model-delete-tombstones-synced-sibling.md b/changelog.d/fixes/10228-provider-model-delete-tombstones-synced-sibling.md deleted file mode 100644 index 10005b7419..0000000000 --- a/changelog.d/fixes/10228-provider-model-delete-tombstones-synced-sibling.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White diff --git a/changelog.d/fixes/10229-audio-bridge-multipart-runtime.md b/changelog.d/fixes/10229-audio-bridge-multipart-runtime.md deleted file mode 100644 index 6e74c302db..0000000000 --- a/changelog.d/fixes/10229-audio-bridge-multipart-runtime.md +++ /dev/null @@ -1 +0,0 @@ -- **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). diff --git a/changelog.d/fixes/10230-deepseek-native-max-effort.md b/changelog.d/fixes/10230-deepseek-native-max-effort.md deleted file mode 100644 index 3a681d3190..0000000000 --- a/changelog.d/fixes/10230-deepseek-native-max-effort.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White diff --git a/changelog.d/fixes/10233-freeaiapikey-endpoint-moved.md b/changelog.d/fixes/10233-freeaiapikey-endpoint-moved.md deleted file mode 100644 index cd7abc5c32..0000000000 --- a/changelog.d/fixes/10233-freeaiapikey-endpoint-moved.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) diff --git a/changelog.d/fixes/10234-monsterapi-deprecation-inert.md b/changelog.d/fixes/10234-monsterapi-deprecation-inert.md deleted file mode 100644 index 62a95d78ab..0000000000 --- a/changelog.d/fixes/10234-monsterapi-deprecation-inert.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) diff --git a/changelog.d/fixes/10244-cliproxy-installer-windows-platform-detection.md b/changelog.d/fixes/10244-cliproxy-installer-windows-platform-detection.md deleted file mode 100644 index bdc134b990..0000000000 --- a/changelog.d/fixes/10244-cliproxy-installer-windows-platform-detection.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) \ No newline at end of file diff --git a/changelog.d/fixes/10247-provider-icon-data-url-save.md b/changelog.d/fixes/10247-provider-icon-data-url-save.md deleted file mode 100644 index 6ad4538b86..0000000000 --- a/changelog.d/fixes/10247-provider-icon-data-url-save.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** compatible/custom providers now save valid Data URL icons and show Add/Edit save failures instead of silently doing nothing ([#10247](https://github.com/diegosouzapw/OmniRoute/pull/10247)) — thanks @xz-dev diff --git a/changelog.d/fixes/10248-custom-model-overrides.md b/changelog.d/fixes/10248-custom-model-overrides.md deleted file mode 100644 index 711e48b9d5..0000000000 --- a/changelog.d/fixes/10248-custom-model-overrides.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(models):** custom model metadata and compatible-provider context overrides now take precedence over discovered metadata, while deleting a synced model no longer creates a permanent tombstone so a later provider sync can restore it ([#10248](https://github.com/diegosouzapw/OmniRoute/pull/10248)) — thanks @jackjinke diff --git a/changelog.d/fixes/10249-dedup-hash-collision.md b/changelog.d/fixes/10249-dedup-hash-collision.md deleted file mode 100644 index f118196dfe..0000000000 --- a/changelog.d/fixes/10249-dedup-hash-collision.md +++ /dev/null @@ -1 +0,0 @@ -- fix(open-sse): stop concurrent requests colliding on the same dedup hash for non-OpenAI target formats (#10249) diff --git a/changelog.d/fixes/10251-text-tool-call-parsing.md b/changelog.d/fixes/10251-text-tool-call-parsing.md deleted file mode 100644 index 9febe54687..0000000000 --- a/changelog.d/fixes/10251-text-tool-call-parsing.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(translator):** Text-format tool calls emitted inline by some models are now converted to proper `tool_use` blocks. Certain models (DeepSeek, Qwen) return tool invocations as `{"name":"Bash","arguments":{…}}` or `TOOL_CALL Read: {"file_path":"…"}` inside the text stream instead of the structured `tool_calls` field. Both formats leaked through the Claude translators as plain text, so Claude Code rendered the raw block and stalled instead of executing the tool. `extractXmlInvokeBlocks` (previously ``-only) now scans for all three shapes in a single pass and emits `content_block_start`/`input_json_delta`/`content_block_stop` events, in both `openai-to-claude` and `gemini-to-claude` (Antigravity) paths ([#10251](https://github.com/diegosouzapw/OmniRoute/pull/10251)) diff --git a/changelog.d/fixes/10261-provider-warning-badges.md b/changelog.d/fixes/10261-provider-warning-badges.md deleted file mode 100644 index 39720b4bf5..0000000000 --- a/changelog.d/fixes/10261-provider-warning-badges.md +++ /dev/null @@ -1 +0,0 @@ -- fix(dashboard): make provider card warning indicators expose the interaction they advertise (#10261) diff --git a/changelog.d/fixes/10265-command-code-provider-api.md b/changelog.d/fixes/10265-command-code-provider-api.md deleted file mode 100644 index b38e4e9d2a..0000000000 --- a/changelog.d/fixes/10265-command-code-provider-api.md +++ /dev/null @@ -1 +0,0 @@ -- fix(command-code): route chat to the documented /provider/v1/chat/completions endpoint instead of the CLI-only /alpha/generate, which Command Code gates/blocks for external callers (#10265) \ No newline at end of file diff --git a/changelog.d/fixes/10272-provider-test-statuscode-propagation.md b/changelog.d/fixes/10272-provider-test-statuscode-propagation.md deleted file mode 100644 index 5102bf9c45..0000000000 --- a/changelog.d/fixes/10272-provider-test-statuscode-propagation.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** preserve validator HTTP status codes in API-key and web connection-test results so callers can distinguish authentication, rate-limit, and upstream failures ([#10272](https://github.com/diegosouzapw/OmniRoute/pull/10272)) — thanks @Zartharas diff --git a/changelog.d/fixes/10284-reasoning-probe-truncated-200.md b/changelog.d/fixes/10284-reasoning-probe-truncated-200.md deleted file mode 100644 index c3ddd311d2..0000000000 --- a/changelog.d/fixes/10284-reasoning-probe-truncated-200.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(sse):** tiny-budget reasoning probes (e.g. Claude Code's `/model` check sends `max_tokens: 1`) are answered with a valid truncated 200 instead of relaying the upstream 5xx "empty response content" — which previously also marked the connection unavailable and poisoned fallback/cooldown bookkeeping for a request that is only a probe ([#10281](https://github.com/diegosouzapw/OmniRoute/issues/10281)) — thanks @harkaranbrar7 diff --git a/changelog.d/fixes/10285-googleflow-video-wrong-path-auth.md b/changelog.d/fixes/10285-googleflow-video-wrong-path-auth.md deleted file mode 100644 index 8e2301cb7c..0000000000 --- a/changelog.d/fixes/10285-googleflow-video-wrong-path-auth.md +++ /dev/null @@ -1 +0,0 @@ -- fix(video): stop advertising the googleflow (Veo) video provider as working and fail fast with a clear diagnostic — its submit/poll endpoints 404 and no server-side OAuth transport can satisfy the working endpoint (#10285) diff --git a/changelog.d/fixes/10293-windows-tailscale-branches.md b/changelog.d/fixes/10293-windows-tailscale-branches.md deleted file mode 100644 index 2ee9f0d1d8..0000000000 --- a/changelog.d/fixes/10293-windows-tailscale-branches.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(build):** stop Turbopack from dead-code-eliminating the Windows Tailscale branches of `src/lib/tailscaleTunnel.ts` in the published build (#10293). The release `dist` is bundled on a Linux runner, and the bundler constant-folds `process.platform`, pruning every non-Linux branch — the Windows installers shipped with no `where` lookup, an always-injected `--socket`, and a lost `net start Tailscale`/windows-default-binary path. The module now reads the platform at runtime via `os.platform()` (a function call a bundler cannot fold), so the Windows branches survive on any build machine; a vitest regression test mocking `os.platform()` → `win32` guards the anti-fold invariant (RED before, GREEN after). \ No newline at end of file diff --git a/changelog.d/fixes/10311-healthcheck-lifecycle-default.md b/changelog.d/fixes/10311-healthcheck-lifecycle-default.md deleted file mode 100644 index 8a27b45d5d..0000000000 --- a/changelog.d/fixes/10311-healthcheck-lifecycle-default.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(ops):** Docker HEALTHCHECK defaults to the lightweight `/healthz` lifecycle probe instead of the heavy `/api/monitoring/health` path, with an `OMNIROUTE_HEALTHCHECK_PATH` opt-in override ([#10311](https://github.com/diegosouzapw/OmniRoute/pull/10311)) \ No newline at end of file diff --git a/changelog.d/fixes/10313-catalog-cache-key-hash.md b/changelog.d/fixes/10313-catalog-cache-key-hash.md deleted file mode 100644 index 5c85689004..0000000000 --- a/changelog.d/fixes/10313-catalog-cache-key-hash.md +++ /dev/null @@ -1 +0,0 @@ -- fix(api): hash the API key before using it as the model-catalog cache Map key (no raw credentials in process heap) (#10313) diff --git a/changelog.d/fixes/10314-combo-error-aggregation.md b/changelog.d/fixes/10314-combo-error-aggregation.md deleted file mode 100644 index 7dd3ef6a60..0000000000 --- a/changelog.d/fixes/10314-combo-error-aggregation.md +++ /dev/null @@ -1 +0,0 @@ -- fix(resilience): keep combo quality and auth failure reasons separate and redact connection labels in terminal errors (#10314) diff --git a/changelog.d/fixes/10319-live-ws-heartbeat-ping.md b/changelog.d/fixes/10319-live-ws-heartbeat-ping.md deleted file mode 100644 index 91ed7b00c5..0000000000 --- a/changelog.d/fixes/10319-live-ws-heartbeat-ping.md +++ /dev/null @@ -1 +0,0 @@ -- fix(dashboard): send periodic WS heartbeat pings so live dashboard connections stop dropping every ~35s (#10319) diff --git a/changelog.d/fixes/10322-process-wide-admission-budget.md b/changelog.d/fixes/10322-process-wide-admission-budget.md deleted file mode 100644 index defab2a7fe..0000000000 --- a/changelog.d/fixes/10322-process-wide-admission-budget.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(chat-body-admission):** restore a single process-wide admission budget — heavyweight leases and queued bytes are now bounded once for the whole process instead of per session, so one session can no longer mint extra capacity or starve others; per-session fairness is preserved via round-robin dispatch ([#10110](https://github.com/diegosouzapw/OmniRoute/issues/10110)) diff --git a/changelog.d/fixes/10329-zai-web-auth-semantics.md b/changelog.d/fixes/10329-zai-web-auth-semantics.md deleted file mode 100644 index c4e6703112..0000000000 --- a/changelog.d/fixes/10329-zai-web-auth-semantics.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** validate Z.ai web Local Storage sessions against the authenticated user-settings endpoint and preserve exact upstream status codes ([#10329](https://github.com/diegosouzapw/OmniRoute/pull/10329)) — thanks @Zartharas diff --git a/changelog.d/fixes/10345-bare-combo-opencode-ids.md b/changelog.d/fixes/10345-bare-combo-opencode-ids.md deleted file mode 100644 index c3a6a499ec..0000000000 --- a/changelog.d/fixes/10345-bare-combo-opencode-ids.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(opencode-plugin):** publish bare combo model ids without the plugin provider prefix so OpenCode can select them ([#10345](https://github.com/diegosouzapw/OmniRoute/issues/10345)) diff --git a/changelog.d/fixes/10346-empty-pool-warn-once.md b/changelog.d/fixes/10346-empty-pool-warn-once.md deleted file mode 100644 index e4b50ef3ff..0000000000 --- a/changelog.d/fixes/10346-empty-pool-warn-once.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(backend):** log `auto/ matched no connected models` once per process per label instead of every minute ([#10346](https://github.com/diegosouzapw/OmniRoute/issues/10346)) diff --git a/changelog.d/fixes/10348-default-logs-redact-client.md b/changelog.d/fixes/10348-default-logs-redact-client.md deleted file mode 100644 index 4c3aa0a00f..0000000000 --- a/changelog.d/fixes/10348-default-logs-redact-client.md +++ /dev/null @@ -1 +0,0 @@ -- fix(backend): redact client IPs and account prefixes from default proxy logs (#10348) diff --git a/changelog.d/fixes/10353-memory-heap-conflict-warn.md b/changelog.d/fixes/10353-memory-heap-conflict-warn.md deleted file mode 100644 index c52b7cc15c..0000000000 --- a/changelog.d/fixes/10353-memory-heap-conflict-warn.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(docker):** warn at boot when `OMNIROUTE_MEMORY_MB` disagrees with `NODE_OPTIONS --max-old-space-size`, and document that the standalone/Docker launcher appends `OMNIROUTE_MEMORY_MB` last ([#10353](https://github.com/diegosouzapw/OmniRoute/issues/10353)) diff --git a/changelog.d/fixes/10365-gitlab-duo-401-fallback.md b/changelog.d/fixes/10365-gitlab-duo-401-fallback.md deleted file mode 100644 index cc05612a00..0000000000 --- a/changelog.d/fixes/10365-gitlab-duo-401-fallback.md +++ /dev/null @@ -1 +0,0 @@ -- fix(providers): GitLab Duo falls back to the public Code Suggestions endpoint when direct_access returns 401 (#10365) \ No newline at end of file diff --git a/changelog.d/fixes/10372-debug-mode-default-false.md b/changelog.d/fixes/10372-debug-mode-default-false.md deleted file mode 100644 index c1a59b4fb3..0000000000 --- a/changelog.d/fixes/10372-debug-mode-default-false.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(db):** `getSettings()` defaults `debugMode` to `false` — fresh installs no longer run in debug mode (persisted `debugMode: true` is preserved) ([#10372](https://github.com/diegosouzapw/OmniRoute/pull/10372) — thanks @lamchun1110) diff --git a/changelog.d/fixes/10374-claude-tool-name-casing-normalization.md b/changelog.d/fixes/10374-claude-tool-name-casing-normalization.md deleted file mode 100644 index 9acf0e08c7..0000000000 --- a/changelog.d/fixes/10374-claude-tool-name-casing-normalization.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(translator):** Consolidate tool-name casing normalization into a single `restoreClaudeToolName` helper reused across every response path (`openai-to-claude`, `gemini-to-claude`, `stream` passthrough, xAI and Antigravity handlers), replacing six hand-copied 7-entry casing maps. The shared helper resolves via the request-side `toolNameMap` first (preserving declared PascalCase and MCP/alias names), then the complete `TOOL_RENAME_MAP` (which already covers `glob`/`grep`/`task`/`todowrite`/`skill`/`askuserquestion`/etc.), then the `#7926` TitleCase→lowercase fallback for map-less clients. This closes the coverage gap that left `TodoWrite` and other tools failing with `Error: No such tool available: todowrite`, fixes a `ReferenceError` in `remapToolNamesInResponse`, and preserves the Gemini thought-signature persistence (`#8979`) and OpenAI→Claude `toolNameMap` restoration that must not regress ([#10374](https://github.com/diegosouzapw/OmniRoute/issues/10374)) diff --git a/changelog.d/fixes/10374-openai-compatible-responses-passthrough.md b/changelog.d/fixes/10374-openai-compatible-responses-passthrough.md deleted file mode 100644 index d735feed1c..0000000000 --- a/changelog.d/fixes/10374-openai-compatible-responses-passthrough.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(responses):** preserve native tool definitions for custom OpenAI-compatible providers when using the Responses API (`/v1/responses`). When `apiType` is set to `"responses"` (or `_omnirouteForceResponsesUpstream` is enabled), OmniRoute passes native tool shapes (`custom` with lark grammars, `namespace`, `local_shell`) directly upstream without running a lossy Responses→Chat→Responses conversion ([#10374](https://github.com/diegosouzapw/OmniRoute/issues/10374)) diff --git a/changelog.d/fixes/10381-free-tier-usage-history.md b/changelog.d/fixes/10381-free-tier-usage-history.md deleted file mode 100644 index 4009855cc0..0000000000 --- a/changelog.d/fixes/10381-free-tier-usage-history.md +++ /dev/null @@ -1 +0,0 @@ -- fix(dashboard): Free Tier 'used this month' now includes live usage_history rows, not just the rolled-up daily summary (#10381) diff --git a/changelog.d/fixes/10393-opencode-rotate-network-throw.md b/changelog.d/fixes/10393-opencode-rotate-network-throw.md deleted file mode 100644 index b0d8e9fb1e..0000000000 --- a/changelog.d/fixes/10393-opencode-rotate-network-throw.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(executors):** OpencodeExecutor and MimocodeExecutor now rotate to the next account on network exceptions (timeout, connection refused/reset) when the failed account has a dedicated proxy, not only on 429 — a throw on one account no longer fails the whole request when other accounts remain. Accounts sharing the default egress (no proxy) fail fast instead of retrying the same outage against every account. The shared rotation mechanics (`pickAccount`/`markCooldown`/`markSuccess`) are now extracted into `accountRotation.ts`, fixing an identical unconditional-cooldown gap that pre-dated this PR in MimocodeExecutor ([#10393](https://github.com/diegosouzapw/OmniRoute/pull/10393)) diff --git a/changelog.d/fixes/10397-header-budget-warn-dedupe.md b/changelog.d/fixes/10397-header-budget-warn-dedupe.md deleted file mode 100644 index d4d117b913..0000000000 --- a/changelog.d/fixes/10397-header-budget-warn-dedupe.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(sse):** the header-budget drop warning fires once per unique dropped-header set instead of on every SSE response (warn-storm fix) ([#10397](https://github.com/diegosouzapw/OmniRoute/pull/10397) — thanks @lamchun1110) diff --git a/changelog.d/fixes/10404-streaming-terminated-empty-completion-failover.md b/changelog.d/fixes/10404-streaming-terminated-empty-completion-failover.md deleted file mode 100644 index d8441a21c1..0000000000 --- a/changelog.d/fixes/10404-streaming-terminated-empty-completion-failover.md +++ /dev/null @@ -1 +0,0 @@ -- fix(sse): fail over combo streaming responses that reach `finish_reason` with zero content, reasoning, or tool_calls instead of forwarding a terminated-but-empty completion (#10404) diff --git a/changelog.d/fixes/10415-vision-bridge-combo-reroute.md b/changelog.d/fixes/10415-vision-bridge-combo-reroute.md deleted file mode 100644 index a3df3019c4..0000000000 --- a/changelog.d/fixes/10415-vision-bridge-combo-reroute.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(guardrails):** Vision Bridge now reroutes whole requests for named combos whose targets have zero vision-capable models (previously such image requests died with `capability_mismatch` when the describe path could not run), and when the fallback describe path also fails for every image the request degrades to explicit `(unavailable)` stub text instead of preserving images the combo cannot consume ([#10415](https://github.com/diegosouzapw/OmniRoute/pull/10415)) — thanks @rqzbeh diff --git a/changelog.d/fixes/10420-antigravity-geoblock-resilience.md b/changelog.d/fixes/10420-antigravity-geoblock-resilience.md deleted file mode 100644 index cb465299b2..0000000000 --- a/changelog.d/fixes/10420-antigravity-geoblock-resilience.md +++ /dev/null @@ -1,2 +0,0 @@ -- **fix(antigravity):** geo-blocked egress (Google "User location is not supported") is now classified (scoped to the Google AI surfaces that emit it: Cloud Code/Gemini Code Assist, Gemini API, Vertex), cached as a 24h per-account exclusion so routing continues with other accounts, and surfaced with an actionable message; the dashboard connection test now probes the real `streamGenerateContent` model surface instead of the non-geo-restricted OAuth userinfo endpoint ([#10420](https://github.com/diegosouzapw/OmniRoute/pull/10420)) — thanks @rqzbeh -- **fix(antigravity):** strip competing-agent identity sentences from system prompts (e.g. "You are a Claude agent, built on Anthropic's Claude Agent SDK.") that Antigravity flags and answers with 429 RESOURCE_EXHAUSTED (port of decolua/9router b566b20) ([#10420](https://github.com/diegosouzapw/OmniRoute/pull/10420)) — thanks @rqzbeh diff --git a/changelog.d/fixes/10424-antigravity-project-autocreate.md b/changelog.d/fixes/10424-antigravity-project-autocreate.md deleted file mode 100644 index 81fc6734c4..0000000000 --- a/changelog.d/fixes/10424-antigravity-project-autocreate.md +++ /dev/null @@ -1,2 +0,0 @@ -- **fix(antigravity):** accounts with an empty Cloud Code `projectId` now heal themselves — failed auto-onboarding (`onboardUser`) attempts are retried after a short backoff instead of being memoized forever, so the missing Google project is created without user action on a later request or token refresh ([#10424](https://github.com/diegosouzapw/OmniRoute/pull/10424)) — thanks @rqzbeh -- **fix(antigravity):** Google deprecated automatic project creation for standard-tier (personal) accounts — when `onboardUser` completes without a project id the account now fails fast with a clear `403 GCP_PROJECT_REQUIRED` message (no more generic 422 or delayed 429 RESOURCE_EXHAUSTED), and a manual GCP Project ID override is available in the connection editor so operators can enter their own project id ([#10424](https://github.com/diegosouzapw/OmniRoute/pull/10424)) — thanks @rqzbeh diff --git a/changelog.d/fixes/10430-antigravity-usage-envelope.md b/changelog.d/fixes/10430-antigravity-usage-envelope.md deleted file mode 100644 index 645045e7ea..0000000000 --- a/changelog.d/fixes/10430-antigravity-usage-envelope.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(usage):** read Gemini `usageMetadata` out of the antigravity `{ response: {...} }` envelope so non-streaming requests log real token usage instead of `IN 0 | OUT 0` (port of decolua/9router#59d858b) ([#10430](https://github.com/diegosouzapw/OmniRoute/pull/10430)) — thanks @rqzbeh diff --git a/changelog.d/fixes/10465-gemini-cached-tokens.md b/changelog.d/fixes/10465-gemini-cached-tokens.md deleted file mode 100644 index 0acd31720a..0000000000 --- a/changelog.d/fixes/10465-gemini-cached-tokens.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(usage):** surface Gemini `cachedContentTokenCount` into `cached_tokens` for non-streaming requests so cache-hit accounting matches the OpenAI/Claude/Responses branches and the streaming path (follow-up to the #10430 envelope fix) ([#10465](https://github.com/diegosouzapw/OmniRoute/pull/10465)) — thanks @rqzbeh diff --git a/changelog.d/fixes/10470-antigravity-byop-account-rotation.md b/changelog.d/fixes/10470-antigravity-byop-account-rotation.md deleted file mode 100644 index 9ec58e152a..0000000000 --- a/changelog.d/fixes/10470-antigravity-byop-account-rotation.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(antigravity):** automatically rotate to a sibling account when one is BYOP (GCP Project ID required, `gcp_project_required` 422) — the account is excluded from selection for 24h and the request succeeds via another account instead of failing fast; the actionable 422 is surfaced only when no sibling exists (follow-up to the #10424 BYOP fast-fail) ([#10470](https://github.com/diegosouzapw/OmniRoute/pull/10470)) — thanks @rqzbeh diff --git a/changelog.d/fixes/10479-mitm-passthrough-misroutes-unknown-hosts.md b/changelog.d/fixes/10479-mitm-passthrough-misroutes-unknown-hosts.md deleted file mode 100644 index 64a6e3733e..0000000000 --- a/changelog.d/fixes/10479-mitm-passthrough-misroutes-unknown-hosts.md +++ /dev/null @@ -1 +0,0 @@ -- fix(mitm): forward passthrough traffic to the actual requested Host instead of misrouting every non-TARGET_HOSTS request to the hardcoded Antigravity sandbox host (#10479) diff --git a/changelog.d/fixes/10482-docker-images-and-basepath.md b/changelog.d/fixes/10482-docker-images-and-basepath.md deleted file mode 100644 index c85ae91937..0000000000 --- a/changelog.d/fixes/10482-docker-images-and-basepath.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(docker):** point the bifrost sidecar at the real `ghcr.io/maximhq/bifrost:v1.6.11` tag and the cliproxyapi sidecar at the official `docker.io/eceasy/cli-proxy-api:v6.9.7` image (the previously pinned tags never existed), and complete the runtime `OMNIROUTE_BASE_PATH` subpath patch for Next 16 standalone (assetPrefix + client env + baked asset URLs) so prebuilt images respect the webpath env var ([#10482](https://github.com/diegosouzapw/OmniRoute/pull/10482)) diff --git a/changelog.d/fixes/10484-hermes-obfuscate-zwj.md b/changelog.d/fixes/10484-hermes-obfuscate-zwj.md deleted file mode 100644 index 5e1dc60de1..0000000000 --- a/changelog.d/fixes/10484-hermes-obfuscate-zwj.md +++ /dev/null @@ -1 +0,0 @@ -- fix(sse): stop ZWJ-obfuscating the substring "hermes" in user messages and hostnames (#10484) diff --git a/changelog.d/fixes/10489-qdrant-health-badge.md b/changelog.d/fixes/10489-qdrant-health-badge.md deleted file mode 100644 index f9c216e8a5..0000000000 --- a/changelog.d/fixes/10489-qdrant-health-badge.md +++ /dev/null @@ -1,2 +0,0 @@ -- **fix(memory):** auto-check Qdrant health on mount and stop the false-red status badge on `/dashboard/memory?tab=engine` — the badge treated "not yet checked" (`health === null`) as a failure, so a healthy Qdrant showed red after every page refresh until "Test connection" was clicked; settings changes now also invalidate the stale result and re-check after the save persists, so a health check racing the settings PUT can no longer keep the badge red until a manual re-test ([#10489](https://github.com/diegosouzapw/OmniRoute/pull/10489)) -- **test(compression):** align source-contract tests with the merged `release/v3.8.50` base (`aa912c42a`) — accept the multi-line `providerTransport` shape in `omniglyph-chatcore-plumbing` and give the pipeline-circuit-breaker fixture a `metadata.executionStages` (both structural changes landed in the base merge) ([#10489](https://github.com/diegosouzapw/OmniRoute/pull/10489)) diff --git a/changelog.d/fixes/10508-cli-readiness-localhost-dns-delay.md b/changelog.d/fixes/10508-cli-readiness-localhost-dns-delay.md deleted file mode 100644 index 6d469fefe2..0000000000 --- a/changelog.d/fixes/10508-cli-readiness-localhost-dns-delay.md +++ /dev/null @@ -1 +0,0 @@ -- fix(cli): use 127.0.0.1 for the readiness health-check poll instead of localhost, avoiding Windows DNS-resolution delays that made a healthy server report as never-ready (#10508) diff --git a/changelog.d/fixes/10517-zed-hosted-oauth-callback-port.md b/changelog.d/fixes/10517-zed-hosted-oauth-callback-port.md deleted file mode 100644 index af2a0d6b4c..0000000000 --- a/changelog.d/fixes/10517-zed-hosted-oauth-callback-port.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** zed-hosted OAuth now redirects the browser back to the dashboard's own loopback port (auto-completing the login), and the manual paste path accepts Zed's user_id/access_token callback URL instead of erroring with "No authorization code found" ([#10517](https://github.com/diegosouzapw/OmniRoute/pull/10517)) - thanks @phatchau036 \ No newline at end of file diff --git a/changelog.d/fixes/10518-token-backed-web-session-update.md b/changelog.d/fixes/10518-token-backed-web-session-update.md deleted file mode 100644 index 78ca1793b8..0000000000 --- a/changelog.d/fixes/10518-token-backed-web-session-update.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** allow token-backed web sessions stored with `authType: "cookie"` to refresh their token through the provider update API ([#10518](https://github.com/diegosouzapw/OmniRoute/pull/10518)) — thanks @Zartharas diff --git a/changelog.d/fixes/10519-token-backed-web-session-test-dispatch.md b/changelog.d/fixes/10519-token-backed-web-session-test-dispatch.md deleted file mode 100644 index 009f0bd2e5..0000000000 --- a/changelog.d/fixes/10519-token-backed-web-session-test-dispatch.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** test token-backed web sessions through their provider validator instead of the OAuth path ([#10519](https://github.com/diegosouzapw/OmniRoute/pull/10519)) — thanks @Zartharas diff --git a/changelog.d/fixes/10521-audit-extra-api-keys-redaction.md b/changelog.d/fixes/10521-audit-extra-api-keys-redaction.md deleted file mode 100644 index 41222522de..0000000000 --- a/changelog.d/fixes/10521-audit-extra-api-keys-redaction.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(compliance):** redact additional provider API keys from audit-log payloads ([#10521](https://github.com/diegosouzapw/OmniRoute/pull/10521)) — thanks @Zartharas diff --git a/changelog.d/fixes/10522-firefly-cookie-validation-alias-miss.md b/changelog.d/fixes/10522-firefly-cookie-validation-alias-miss.md deleted file mode 100644 index 3545d77ebb..0000000000 --- a/changelog.d/fixes/10522-firefly-cookie-validation-alias-miss.md +++ /dev/null @@ -1 +0,0 @@ -- fix(providers): register a real Firefly auth probe under both the `firefly` alias and the `adobe-firefly` canonical id, and normalize the provider id before the generic web-cookie fallback, so a Firefly connection stops always reporting "Provider validation not supported" (#10522) diff --git a/changelog.d/fixes/10523-servicesupervisor-port-flake.md b/changelog.d/fixes/10523-servicesupervisor-port-flake.md deleted file mode 100644 index 1a98ea7fa2..0000000000 --- a/changelog.d/fixes/10523-servicesupervisor-port-flake.md +++ /dev/null @@ -1 +0,0 @@ -- fix(services): isolate probeBeforeSpawn adoption tests on distinct ports to stop the order-dependent flake (#10523) \ No newline at end of file diff --git a/changelog.d/fixes/10527-deepseek-web-context-amnesia.md b/changelog.d/fixes/10527-deepseek-web-context-amnesia.md deleted file mode 100644 index 4eadc0040a..0000000000 --- a/changelog.d/fixes/10527-deepseek-web-context-amnesia.md +++ /dev/null @@ -1 +0,0 @@ -- fix(sse): auto-replay a bounded multi-turn trajectory in the DeepSeek Web prompt builder for clients that never send `tools[]`, so agentic clients like Cline stop losing the original task after a couple of turns (#10527) diff --git a/changelog.d/fixes/10528-direct-dispatcher-response-start-timeout.md b/changelog.d/fixes/10528-direct-dispatcher-response-start-timeout.md deleted file mode 100644 index 9354b02822..0000000000 --- a/changelog.d/fixes/10528-direct-dispatcher-response-start-timeout.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(network):** direct (no-proxy) egress now bounds each attempt's response-start window (default 30s, `OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS`) and retries once on a fresh no-keep-alive socket, so a silently-dropped pooled keep-alive connection can no longer stall direct providers (opencode-go, command-code) until a service restart ([#10214](https://github.com/diegosouzapw/OmniRoute/issues/10214)) diff --git a/changelog.d/fixes/10530-codex-combo-context.md b/changelog.d/fixes/10530-codex-combo-context.md deleted file mode 100644 index 29ada2699a..0000000000 --- a/changelog.d/fixes/10530-codex-combo-context.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(models):** align Codex GPT-5.6 context limits with the Codex catalog and honor model context overrides when advertising combos ([#10530](https://github.com/diegosouzapw/OmniRoute/issues/10530)) diff --git a/changelog.d/fixes/10536-llmlingua-2-2.0.5-drop-tfjs.md b/changelog.d/fixes/10536-llmlingua-2-2.0.5-drop-tfjs.md deleted file mode 100644 index 11a1845715..0000000000 --- a/changelog.d/fixes/10536-llmlingua-2-2.0.5-drop-tfjs.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(deps):** upgrade `@atjsh/llmlingua-2` from 2.0.3 to 2.0.5 and remove `@tensorflow/tfjs` from the LLMLingua SLM stack — 2.0.5 adds official Transformers.js v4 support (peers `@huggingface/transformers` at `^3.5.2 || ^4.0.0`) and 2.0.4+ no longer requires TensorFlow.js, restoring compatibility with OmniRoute's Transformers.js v4 while dropping the largest single contributor to the optional runtime footprint ([#10536](https://github.com/diegosouzapw/OmniRoute/issues/10536)) diff --git a/changelog.d/fixes/10540-deepseek-v4-efforts.md b/changelog.d/fixes/10540-deepseek-v4-efforts.md deleted file mode 100644 index 339758ebcf..0000000000 --- a/changelog.d/fixes/10540-deepseek-v4-efforts.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(deepseek):** Advertise `none`, `low`, `high`, and `max` for V4 Pro and Flash, derive OpenCode Go effort aliases from base-model metadata, and route those models through native Responses ([#10540](https://github.com/diegosouzapw/OmniRoute/pull/10540)) — thanks @jackjinke diff --git a/changelog.d/fixes/10544-a2a-tasks-timing-safe.md b/changelog.d/fixes/10544-a2a-tasks-timing-safe.md deleted file mode 100644 index f68ac49e3d..0000000000 --- a/changelog.d/fixes/10544-a2a-tasks-timing-safe.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(a2a):** use a constant-time bearer compare in `/api/a2a/tasks` via `crypto.timingSafeEqual`, matching the `tokensMatch` helper already used in `src/app/a2a/route.ts` and removing the last non-constant secret comparison in the repo ([#10544](https://github.com/diegosouzapw/OmniRoute/pull/10544)) diff --git a/changelog.d/fixes/10550-responses-reasoning-transport.md b/changelog.d/fixes/10550-responses-reasoning-transport.md deleted file mode 100644 index e2b40cdb8c..0000000000 --- a/changelog.d/fixes/10550-responses-reasoning-transport.md +++ /dev/null @@ -1 +0,0 @@ -- Preserve portable plaintext reasoning by default across streaming and non-streaming Chat Completions and Responses routes while keeping provider-bound opaque state target-compatible. Direct requests drop incompatible continuation reasoning by default; combos can explicitly skip incompatible targets without mutating the request. Known providers no longer show redundant encrypted-reasoning controls. (#10550, #10959) diff --git a/changelog.d/fixes/10553-list-models-card-hardcoded-null.md b/changelog.d/fixes/10553-list-models-card-hardcoded-null.md deleted file mode 100644 index 9f30aa6346..0000000000 --- a/changelog.d/fixes/10553-list-models-card-hardcoded-null.md +++ /dev/null @@ -1 +0,0 @@ -- fix(dashboard): show the real model count on the "List Models" endpoint card instead of a permanent "—" (#10553) diff --git a/changelog.d/fixes/10557-fedora-hostname-bind.md b/changelog.d/fixes/10557-fedora-hostname-bind.md deleted file mode 100644 index 30eb3c6d10..0000000000 --- a/changelog.d/fixes/10557-fedora-hostname-bind.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(cli):** ignore the operating system `HOSTNAME` when choosing the server bind address on Linux and macOS, preventing startup failures when the shell hostname differs from `os.hostname()`; use `OMNIROUTE_SERVER_HOST` for explicit non-Windows configuration while preserving the legacy `HOSTNAME` fallback on Windows ([#10557](https://github.com/diegosouzapw/OmniRoute/pull/10557), closes [#10492](https://github.com/diegosouzapw/OmniRoute/issues/10492)) — thanks @redzrush101 diff --git a/changelog.d/fixes/10571-opencode-session-stability-free-tier-routing.md b/changelog.d/fixes/10571-opencode-session-stability-free-tier-routing.md deleted file mode 100644 index bdfe33165a..0000000000 --- a/changelog.d/fixes/10571-opencode-session-stability-free-tier-routing.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** OpenCode `x-opencode-session` now derives a stable, conversation-scoped fingerprint via `generateSessionId()` instead of a fresh random UUID per request, so upstream prompt caching can hit across requests in the same conversation; bare `big-pickle`/`*-free` model ids now keep routing to an active opencode-family connection even when its synced catalog is temporarily stale; and bare requests to no-auth catalog providers (e.g. `opencode`) now echo the listing-valid `/` form in `response.model` so clients validating against `/v1/models` don't warn ([#10571](https://github.com/diegosouzapw/OmniRoute/pull/10571)) diff --git a/changelog.d/fixes/10575-mcp-github-tool-search.md b/changelog.d/fixes/10575-mcp-github-tool-search.md deleted file mode 100644 index 108466845f..0000000000 --- a/changelog.d/fixes/10575-mcp-github-tool-search.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(mcp):** make GitHub skill tools discoverable through `omniroute_tool_search` diff --git a/changelog.d/fixes/10577-crof-stale-seed-catalog.md b/changelog.d/fixes/10577-crof-stale-seed-catalog.md deleted file mode 100644 index c4fa4da086..0000000000 --- a/changelog.d/fixes/10577-crof-stale-seed-catalog.md +++ /dev/null @@ -1 +0,0 @@ -- fix(providers): remove 10 retired model ids from the crof seed catalog so /v1/models stops advertising models crof.ai no longer serves (#10577) diff --git a/changelog.d/fixes/10583-stt-nested-model-credential-fallback.md b/changelog.d/fixes/10583-stt-nested-model-credential-fallback.md deleted file mode 100644 index 601915df18..0000000000 --- a/changelog.d/fixes/10583-stt-nested-model-credential-fallback.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(audio):** when a prefix-matched STT provider has no credentials, retry gateways that list the same nested model id (e.g. `deepgram/nova-3` → `openrouter/deepgram/nova-3`) and mention those ids in the 400; stop documenting bare `deepgram/nova-3` as the default example ([#10583](https://github.com/diegosouzapw/OmniRoute/issues/10583)) diff --git a/changelog.d/fixes/10586-audio-alias-prefix-gap.md b/changelog.d/fixes/10586-audio-alias-prefix-gap.md deleted file mode 100644 index 845f00e7dd..0000000000 --- a/changelog.d/fixes/10586-audio-alias-prefix-gap.md +++ /dev/null @@ -1 +0,0 @@ -- fix(sse): resolve the short provider-alias prefix (e.g. `el/`) advertised by GET /v1/models for audio speech, transcription and translation model ids (#10586) diff --git a/changelog.d/fixes/10589-elevenlabs-voice-mapping.md b/changelog.d/fixes/10589-elevenlabs-voice-mapping.md deleted file mode 100644 index 58efe3fd72..0000000000 --- a/changelog.d/fixes/10589-elevenlabs-voice-mapping.md +++ /dev/null @@ -1 +0,0 @@ -- fix(sse): map OpenAI-compat voice names to real ElevenLabs voice_ids in direct TTS (#10589) diff --git a/changelog.d/fixes/10592-playground-chattab-endpoint-routing.md b/changelog.d/fixes/10592-playground-chattab-endpoint-routing.md deleted file mode 100644 index ca602b9122..0000000000 --- a/changelog.d/fixes/10592-playground-chattab-endpoint-routing.md +++ /dev/null @@ -1 +0,0 @@ -- fix(dashboard): route the Playground's ChatTab "Send" through the endpoint actually selected in StudioConfigPane (`search`, `web.fetch`, etc.) instead of always POSTing to `/api/v1/chat/completions`, fixing the false "No active credentials for provider" 404 when testing search-only providers (#10592) diff --git a/changelog.d/fixes/10594-freepik-magnific-api.md b/changelog.d/fixes/10594-freepik-magnific-api.md deleted file mode 100644 index 4c1c59a701..0000000000 --- a/changelog.d/fixes/10594-freepik-magnific-api.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** Magnific Mystic is now the canonical provider (`/dashboard/providers/magnific`, `magnific/`). It uses the Magnific API (`api.magnific.com` + `x-magnific-api-key`), dashboard Test Connection validates keys without starting a paid generation, and the old `freepik` slug remains a legacy alias ([#10594](https://github.com/diegosouzapw/OmniRoute/pull/10594)) diff --git a/changelog.d/fixes/10597-combo-log-error-body.md b/changelog.d/fixes/10597-combo-log-error-body.md deleted file mode 100644 index ff6608947c..0000000000 --- a/changelog.d/fixes/10597-combo-log-error-body.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(sse):** Include the redacted upstream error body in the per-target COMBO failure log (`Model X failed, trying next`) so operators can triage a 400/500 without reproducing the request ([#10597](https://github.com/diegosouzapw/OmniRoute/issues/10597)) diff --git a/changelog.d/fixes/10601-xai-800-message-limit.md b/changelog.d/fixes/10601-xai-800-message-limit.md deleted file mode 100644 index 3dcd33ab8e..0000000000 --- a/changelog.d/fixes/10601-xai-800-message-limit.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(xai):** trim Chat Completions `messages` and Responses `input` to xAI's 800-item history cap before dispatch, so long tool loops no longer die on `413 Chat history exceeds the 800-message limit` ([#10601](https://github.com/diegosouzapw/OmniRoute/pull/10601)) diff --git a/changelog.d/fixes/10612-cli-token-machine-id-interop.md b/changelog.d/fixes/10612-cli-token-machine-id-interop.md deleted file mode 100644 index 48ec5b8e1d..0000000000 --- a/changelog.d/fixes/10612-cli-token-machine-id-interop.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(cli):** derive the machine-id token correctly under plain Node — `await import("node-machine-id")` puts the CJS exports on `.default`, so the destructured `machineIdSync` was `undefined` and the catch blanked the token, sending every management request unauthenticated; `OMNIROUTE_CLI_SALT` rotation is now honored too ([#10612](https://github.com/diegosouzapw/OmniRoute/pull/10612)) diff --git a/changelog.d/fixes/10613-setup-provider-api-key-collision.md b/changelog.d/fixes/10613-setup-provider-api-key-collision.md deleted file mode 100644 index 0b8c3095f0..0000000000 --- a/changelog.d/fixes/10613-setup-provider-api-key-collision.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(cli):** `omniroute setup --add-provider --api-key ` no longer aborts with "Provider API key is required" — Commander bound the value to the program-level `--api-key` (the OmniRoute server key), leaving the subcommand's own option undefined; `OMNIROUTE_API_KEY` now works as the error message advertised ([#10613](https://github.com/diegosouzapw/OmniRoute/pull/10613)) diff --git a/changelog.d/fixes/10615-api-models-v1-models-id-mismatch.md b/changelog.d/fixes/10615-api-models-v1-models-id-mismatch.md deleted file mode 100644 index 8cee943efa..0000000000 --- a/changelog.d/fixes/10615-api-models-v1-models-id-mismatch.md +++ /dev/null @@ -1 +0,0 @@ -- fix(dashboard): make /api/models agree with /v1/models on synced-catalog coverage instead of reporting stale models as available (#10615) diff --git a/changelog.d/fixes/10686-combo-quota-token-limit-await.md b/changelog.d/fixes/10686-combo-quota-token-limit-await.md deleted file mode 100644 index a9e7b910e3..0000000000 --- a/changelog.d/fixes/10686-combo-quota-token-limit-await.md +++ /dev/null @@ -1 +0,0 @@ -- **Combo routing:** await each connection's token limit before reserving quota. The old lookup treated the `Promise` as a connection and dropped `rateLimitOverrides.tpm` ([#10686](https://github.com/diegosouzapw/OmniRoute/pull/10686)). diff --git a/changelog.d/fixes/10702-vision-bridge-alias-credential-mismatch.md b/changelog.d/fixes/10702-vision-bridge-alias-credential-mismatch.md deleted file mode 100644 index dcd1c488ae..0000000000 --- a/changelog.d/fixes/10702-vision-bridge-alias-credential-mismatch.md +++ /dev/null @@ -1 +0,0 @@ -- fix(guardrails): resolve the public provider alias before querying credentials in the Vision Bridge router, so command-code/opencode (and any alias!=id provider) are no longer reported as "unusable" despite active connections (#10702) diff --git a/changelog.d/fixes/10703-modality-bridge-vision-model-filter.md b/changelog.d/fixes/10703-modality-bridge-vision-model-filter.md deleted file mode 100644 index 1cefad69a5..0000000000 --- a/changelog.d/fixes/10703-modality-bridge-vision-model-filter.md +++ /dev/null @@ -1 +0,0 @@ -- fix(dashboard): filter the Modality Bridge Vision model picker to vision-capable models, matching the sibling Video/Audio tabs (#10703) diff --git a/changelog.d/fixes/10705-zero-input-token-sanitization-bug.md b/changelog.d/fixes/10705-zero-input-token-sanitization-bug.md deleted file mode 100644 index aa22dbd64a..0000000000 --- a/changelog.d/fixes/10705-zero-input-token-sanitization-bug.md +++ /dev/null @@ -1 +0,0 @@ -- fix(usage): repair provider-reported input_tokens: 0 on non-trivial requests instead of passing it through unrepaired (#10705) diff --git a/changelog.d/fixes/10710-10711-cli-tools-timeout-hermes-keyid.md b/changelog.d/fixes/10710-10711-cli-tools-timeout-hermes-keyid.md deleted file mode 100644 index 4d9a6ba0b4..0000000000 --- a/changelog.d/fixes/10710-10711-cli-tools-timeout-hermes-keyid.md +++ /dev/null @@ -1 +0,0 @@ -- fix(cli): distinguish a CLI-probe timeout from a genuinely absent binary in locateCommand, and resolve the Hermes Agent Apply flow's `keyId` server-side instead of writing the `YOUR_OMNIROUTE_API_KEY_HERE` placeholder (#10710, #10711) diff --git a/changelog.d/fixes/10713-runtime-repair-npm12-allow-scripts.md b/changelog.d/fixes/10713-runtime-repair-npm12-allow-scripts.md deleted file mode 100644 index 17c59d47c0..0000000000 --- a/changelog.d/fixes/10713-runtime-repair-npm12-allow-scripts.md +++ /dev/null @@ -1 +0,0 @@ -- fix(cli): pass --allow-scripts for the runtime's own npm-installed dependencies, so npm 12+'s default install-scripts block no longer silently skips better-sqlite3's native build (#10713) diff --git a/changelog.d/fixes/10714-provider-metrics-ghost-deleted-provider.md b/changelog.d/fixes/10714-provider-metrics-ghost-deleted-provider.md deleted file mode 100644 index 050728ec08..0000000000 --- a/changelog.d/fixes/10714-provider-metrics-ghost-deleted-provider.md +++ /dev/null @@ -1 +0,0 @@ -- fix(db): filter `getProviderMetrics()` to providers with a live `provider_connections` row so a deleted provider stops permanently ghost-haunting the Home "Provider Topology" widget (#10714) diff --git a/changelog.d/fixes/10720-proxy-password-only-auth.md b/changelog.d/fixes/10720-proxy-password-only-auth.md deleted file mode 100644 index ca51ccba65..0000000000 --- a/changelog.d/fixes/10720-proxy-password-only-auth.md +++ /dev/null @@ -1 +0,0 @@ -- fix(proxy): keep password-only proxy credentials instead of dropping them when no username is set (#10720) diff --git a/changelog.d/fixes/10727-meta-ai-ws-timeout-diagnostics.md b/changelog.d/fixes/10727-meta-ai-ws-timeout-diagnostics.md deleted file mode 100644 index f204684baa..0000000000 --- a/changelog.d/fixes/10727-meta-ai-ws-timeout-diagnostics.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(executors):** the Meta AI (muse-spark-web) WebSocket send-message timeout now reports the socket's `readyState` at the moment it fires, so a "Meta AI WS timed out" failure can be told apart as either the connection never opening (`readyState=0`) or opening successfully and then going silent (`readyState=1`) — the exact ambiguity that made #10727 undiagnosable from logs alone (#10727). diff --git a/changelog.d/fixes/10732-copilot-m365-invocation-refresh.md b/changelog.d/fixes/10732-copilot-m365-invocation-refresh.md deleted file mode 100644 index acbfbbe693..0000000000 --- a/changelog.d/fixes/10732-copilot-m365-invocation-refresh.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** copilot-m365-web chat turns no longer surface as `(empty response)` — the type:4 invocation is aligned with the 2026-08 wire shape and now carries its type:1 Metrics follow-up in the same socket write, and the access token pre-flight-refreshes from a stored refresh_token instead of requiring a DevTools re-capture every ~75 minutes ([#10732](https://github.com/diegosouzapw/OmniRoute/pull/10732) — thanks @acc0mplish) diff --git a/changelog.d/fixes/10734-combo-context-generic-default.md b/changelog.d/fixes/10734-combo-context-generic-default.md deleted file mode 100644 index 988c435d4e..0000000000 --- a/changelog.d/fixes/10734-combo-context-generic-default.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(catalog):** stop counting `getTokenLimit()`'s generic 128k catch-all as a known combo window, so `/v1/models` advertises the min of sourced member contexts instead of collapsing a 500k combo to 128k ([#10734](https://github.com/diegosouzapw/OmniRoute/issues/10734)) diff --git a/changelog.d/fixes/10735-search-provider-named-errors.md b/changelog.d/fixes/10735-search-provider-named-errors.md deleted file mode 100644 index 0e82f36aa8..0000000000 --- a/changelog.d/fixes/10735-search-provider-named-errors.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(search):** name `/v1/search` 502s with provider id and sanitized Node cause code, without hostnames ([#10735](https://github.com/diegosouzapw/OmniRoute/issues/10735)) diff --git a/changelog.d/fixes/10736-corrupt-rotate-fence.md b/changelog.d/fixes/10736-corrupt-rotate-fence.md deleted file mode 100644 index dd2abc4fc4..0000000000 --- a/changelog.d/fixes/10736-corrupt-rotate-fence.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(db):** pause call-log rotation and record SQLITE_CORRUPT on `/api/db/health` instead of retrying writes against a malformed pager ([#10736](https://github.com/diegosouzapw/OmniRoute/issues/10736)) diff --git a/changelog.d/fixes/10765-rtk-unconditional-stats-cpu.md b/changelog.d/fixes/10765-rtk-unconditional-stats-cpu.md deleted file mode 100644 index ff36462d47..0000000000 --- a/changelog.d/fixes/10765-rtk-unconditional-stats-cpu.md +++ /dev/null @@ -1 +0,0 @@ -- fix(compression): skip the expensive `createCompressionStats()` pass in RTK when no message was actually compressed, matching every sibling stacked engine (#10765) diff --git a/changelog.d/fixes/10769-cache-stats-real-cache.md b/changelog.d/fixes/10769-cache-stats-real-cache.md deleted file mode 100644 index baaacc660d..0000000000 --- a/changelog.d/fixes/10769-cache-stats-real-cache.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(api):** `/api/cache/stats` reported the prompt-cache LRU, which no request path ever writes to — it answered `0 hit / 0 miss, size 0` while the semantic cache served real traffic, and the Health and Usage dashboards rendered that as fact. It now reports the semantic cache's in-memory entries, with the same response shape ([#PRNUM](https://github.com/diegosouzapw/OmniRoute/pull/10769)) — thanks @Poid-ZA, who first fixed this in #9446. diff --git a/changelog.d/fixes/10770-console-interceptor-message-fidelity.md b/changelog.d/fixes/10770-console-interceptor-message-fidelity.md deleted file mode 100644 index c35260f36a..0000000000 --- a/changelog.d/fixes/10770-console-interceptor-message-fidelity.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(logging):** the app log is filterable and readable again. Entries from the tagged logger (`[LEVEL] [TAG] message`) were filed under the level instead of the component, and printf format strings were never applied, so `%s`/`%d` stayed literal with the values trailing behind them unlabelled — including every LiveWS connection line, where the format is deliberate hardening against injected format specifiers ([#PRNUM](https://github.com/diegosouzapw/OmniRoute/pull/10770)). diff --git a/changelog.d/fixes/10774-claude-code-flat-rate.md b/changelog.d/fixes/10774-claude-code-flat-rate.md deleted file mode 100644 index ea09e2b208..0000000000 --- a/changelog.d/fixes/10774-claude-code-flat-rate.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(analytics):** Claude Code (`claude`/`cc`) is a flat-rate subscription, so cost analytics reports `$0` for it instead of estimating Anthropic list prices — the metered `anthropic` API keeps its real cost, and budget/quota/routing still estimate as before ([#10774](https://github.com/diegosouzapw/OmniRoute/pull/10774)) — thanks @electrumguy diff --git a/changelog.d/fixes/10781-wal-truncate-scheduler.md b/changelog.d/fixes/10781-wal-truncate-scheduler.md deleted file mode 100644 index 4eb13a271b..0000000000 --- a/changelog.d/fixes/10781-wal-truncate-scheduler.md +++ /dev/null @@ -1 +0,0 @@ -- fix(db): periodically run `wal_checkpoint(TRUNCATE)` so the SQLite WAL file shrinks on long-running servers (default 6h, override with `OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS`, `0` disables) (#10781) diff --git a/changelog.d/fixes/10782-ws-heartbeat-ping-pong.md b/changelog.d/fixes/10782-ws-heartbeat-ping-pong.md deleted file mode 100644 index 23aeaf3d3a..0000000000 --- a/changelog.d/fixes/10782-ws-heartbeat-ping-pong.md +++ /dev/null @@ -1 +0,0 @@ -- fix(sse): replace LiveWS's application-only liveness check with a protocol-level `ws.ping()`/`pong` heartbeat (RFC 6455 §5.5.2) alongside the existing one, so a read-only dashboard subscriber that never sends anything survives the connection timeout — a socket that stops reading frames entirely is still reaped exactly as before (#10782) diff --git a/changelog.d/fixes/10788-ollama-cloud-effort-tiers.md b/changelog.d/fixes/10788-ollama-cloud-effort-tiers.md deleted file mode 100644 index 0437576d38..0000000000 --- a/changelog.d/fixes/10788-ollama-cloud-effort-tiers.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(open-sse):** declare `supportedThinkingEfforts` (`low`/`medium`/`high`/`max`) on Ollama Cloud's `glm-5.1`, `glm-5.2`, `deepseek-v4-pro` and `deepseek-v4-flash` registry entries so the catalog's `appendSyncedEffortVariants()` pass — which only synthesizes selectable `-low`/`-high`/`-max` model ids from an already-populated `capabilities.effort_tiers` — can expose an effort selector for these reasoning-capable models, matching what `gpt-oss:20b`/`gpt-oss:120b` already had (#10788) diff --git a/changelog.d/fixes/10792-double-transport-retry-scope.md b/changelog.d/fixes/10792-double-transport-retry-scope.md deleted file mode 100644 index 337b680add..0000000000 --- a/changelog.d/fixes/10792-double-transport-retry-scope.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(resilience):** scope the same-account transport retry (#9708) out of emergency-fallback and combo hops — it was retrying the free fallback model and combo targets too, doubling upstream calls and corrupting the terminal error status on those paths. diff --git a/changelog.d/fixes/10798-respect-log-level-provider-catalog.md b/changelog.d/fixes/10798-respect-log-level-provider-catalog.md deleted file mode 100644 index 3a11aab909..0000000000 --- a/changelog.d/fixes/10798-respect-log-level-provider-catalog.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(opencode-plugin):** respect log level in provider.models() catalog path so debug/info/warn messages are suppressed when `features.logLevel` is set to `"error"` ([#10798](https://github.com/diegosouzapw/OmniRoute/pull/10798)) — thanks @tientien17 diff --git a/changelog.d/fixes/10799-provider-health-inconclusive-probes.md b/changelog.d/fixes/10799-provider-health-inconclusive-probes.md deleted file mode 100644 index 72aacacee0..0000000000 --- a/changelog.d/fixes/10799-provider-health-inconclusive-probes.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** Keep NVIDIA timeout probes and generic Antigravity/AGY HTTP 400 probes from poisoning credential health while preserving explicit Google geo-block handling ([#10799](https://github.com/diegosouzapw/OmniRoute/pull/10799)) — thanks @Zartharas diff --git a/changelog.d/fixes/10815-kiro-oauth-profilearn-dedup.md b/changelog.d/fixes/10815-kiro-oauth-profilearn-dedup.md deleted file mode 100644 index 51768aab4c..0000000000 --- a/changelog.d/fixes/10815-kiro-oauth-profilearn-dedup.md +++ /dev/null @@ -1 +0,0 @@ -- fix(db): disambiguate `createProviderConnection()`'s OAuth email dedup by `providerSpecificData.profileArn` in addition to `username`, so adding a second Kiro/AWS profile with the same email creates a new connection instead of silently merging into the first (#10815) diff --git a/changelog.d/fixes/10815-kiro-social-multi-account.md b/changelog.d/fixes/10815-kiro-social-multi-account.md deleted file mode 100644 index 45b2cfed59..0000000000 --- a/changelog.d/fixes/10815-kiro-social-multi-account.md +++ /dev/null @@ -1 +0,0 @@ -- fix(oauth): stop treating the Kiro profile ARN as an account identity in `findKiroConnectionByIdentity()`, so a second Google/GitHub social login creates a new connection instead of overwriting the first — distinct Builder ID accounts share the same CodeWhisperer profile ARN, and the social token is not a JWT, so no e-mail was available to disambiguate them (#10815) diff --git a/changelog.d/fixes/10832-unprefixed-dalle3.md b/changelog.d/fixes/10832-unprefixed-dalle3.md deleted file mode 100644 index 2dfd970b13..0000000000 --- a/changelog.d/fixes/10832-unprefixed-dalle3.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(images):** register OpenAI `dall-e-3` in the image registry so unprefixed `dall-e-3` (and `openai/dall-e-3`) route to OpenAI Images instead of Microsoft Designer Web, and so the chat catalog no longer lists `openai/dall-e-3` as a 128k chat model ([#10832](https://github.com/diegosouzapw/OmniRoute/issues/10832)) diff --git a/changelog.d/fixes/10843-outbound-guard-mapped-ipv4.md b/changelog.d/fixes/10843-outbound-guard-mapped-ipv4.md deleted file mode 100644 index 2894ff65b0..0000000000 --- a/changelog.d/fixes/10843-outbound-guard-mapped-ipv4.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(security):** Outbound URL guard now resolves IPv4-mapped IPv6 literals to their embedded address, so `[::ffff:169.254.169.254]` is refused by the unconditional cloud-metadata block like its dotted spelling; `[::]` is refused alongside `0.0.0.0` ([#10843](https://github.com/diegosouzapw/OmniRoute/pull/10843)) — thanks @ntdat812 diff --git a/changelog.d/fixes/10848-image-scan-cookie-bridge.md b/changelog.d/fixes/10848-image-scan-cookie-bridge.md deleted file mode 100644 index 07e0f20291..0000000000 --- a/changelog.d/fixes/10848-image-scan-cookie-bridge.md +++ /dev/null @@ -1 +0,0 @@ -- fix(config): exclude cookie-auth image bridges (chatgpt-web, gemini-web) from the unprefixed model scan so a bare id never silently binds to an unofficial web bridge (#10848) diff --git a/changelog.d/fixes/10849-search-provider-opaque-400.md b/changelog.d/fixes/10849-search-provider-opaque-400.md deleted file mode 100644 index a8982fb194..0000000000 --- a/changelog.d/fixes/10849-search-provider-opaque-400.md +++ /dev/null @@ -1 +0,0 @@ -- fix(api): POST /v1/search now replies with a named `Unknown search provider: ` error (and field-named validation messages) instead of an opaque `Invalid request` for unrecognized or short-alias provider ids like `brave`/`serper` (#10849) diff --git a/changelog.d/fixes/10850-readyz-alias.md b/changelog.d/fixes/10850-readyz-alias.md deleted file mode 100644 index 94e62739ba..0000000000 --- a/changelog.d/fixes/10850-readyz-alias.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(api):** alias `GET`/`HEAD` `/readyz` to `/healthz` so Kubernetes readiness probes do not 404 ([#10850](https://github.com/diegosouzapw/OmniRoute/issues/10850)) diff --git a/changelog.d/fixes/10851-openapi-spec-auth-contract.md b/changelog.d/fixes/10851-openapi-spec-auth-contract.md deleted file mode 100644 index e2b038592c..0000000000 --- a/changelog.d/fixes/10851-openapi-spec-auth-contract.md +++ /dev/null @@ -1 +0,0 @@ -- Document the conditional management authentication and 401/403 responses for `GET /api/openapi/spec`. diff --git a/changelog.d/fixes/10853-i18n-disabled-mistranslation.md b/changelog.d/fixes/10853-i18n-disabled-mistranslation.md deleted file mode 100644 index 836cc6491e..0000000000 --- a/changelog.d/fixes/10853-i18n-disabled-mistranslation.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(i18n):** The "Disabled" status no longer renders as the noun for a person with a disability in Japanese, Spanish, Hindi, Polish, Telugu, Urdu and both Chinese locales — 24 strings now use each catalog's existing wording (ja 無効, es Deshabilitado, hi अक्षम, pl Wyłączone, te నిలిపివేయబడింది, ur غیر فعال, zh-CN 已禁用, zh-TW 已停用) ([#10812](https://github.com/diegosouzapw/OmniRoute/issues/10812), [#10853](https://github.com/diegosouzapw/OmniRoute/pull/10853)) — thanks @ntdat812 diff --git a/changelog.d/fixes/10854-skills-marketplace-owner.md b/changelog.d/fixes/10854-skills-marketplace-owner.md deleted file mode 100644 index e80a109f77..0000000000 --- a/changelog.d/fixes/10854-skills-marketplace-owner.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(skills):** Marketplace-installed skills are available to API-key-scoped requests, including existing SkillsMP and skills.sh installs ([#10854](https://github.com/diegosouzapw/OmniRoute/pull/10854)) — thanks @kriptoburak diff --git a/changelog.d/fixes/10857-hide-auto-models-when-routing-disabled.md b/changelog.d/fixes/10857-hide-auto-models-when-routing-disabled.md deleted file mode 100644 index 39da596ee3..0000000000 --- a/changelog.d/fixes/10857-hide-auto-models-when-routing-disabled.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(catalog):** `/v1/models` no longer advertises the built-in `auto/*` ids while auto routing is disabled — they were listed but rejected at request time with `Auto routing is disabled` ([#10831](https://github.com/diegosouzapw/OmniRoute/issues/10831), [#10857](https://github.com/diegosouzapw/OmniRoute/pull/10857)) — thanks @ntdat812 diff --git a/changelog.d/fixes/10858-base64-file-token-estimate.md b/changelog.d/fixes/10858-base64-file-token-estimate.md deleted file mode 100644 index 18d8104b10..0000000000 --- a/changelog.d/fixes/10858-base64-file-token-estimate.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(context):** Base64 file payloads (OpenAI `file` parts, Responses `input_file`, Claude `document` blocks) are budgeted like the Gemini `inlineData` path instead of being counted as prompt text — a ~1MB PDF estimated at 350k tokens and was rejected on the context limit before reaching the provider's document pipeline ([#10840](https://github.com/diegosouzapw/OmniRoute/issues/10840), [#10858](https://github.com/diegosouzapw/OmniRoute/pull/10858)) — thanks @ntdat812 diff --git a/changelog.d/fixes/10860-mcp-upstream-fetch-timeout.md b/changelog.d/fixes/10860-mcp-upstream-fetch-timeout.md deleted file mode 100644 index a23aeed2a1..0000000000 --- a/changelog.d/fixes/10860-mcp-upstream-fetch-timeout.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(mcp):** MCP tool calls that wait on a model provider no longer abort after 10 seconds. `omniRouteFetch` applied a single hardcoded `AbortSignal.timeout(10000)` to every internal hop, and `omniroute_route_request` — which posts to `/v1/chat/completions` and waits on the upstream provider, plus auto-combo candidate probing before a provider is even chosen — passed no signal of its own, so it inherited it. Any route slower than 10s failed from the MCP side while the identical request succeeded through the REST API. `omniroute_web_search` and `omniroute_web_fetch` in the same file already carried an explicit 60s signal, so that value is now shared by all three provider-bound calls instead of being repeated as a literal, while management reads (health, resilience, rate limits, combos, quota, usage) keep their fast-fail 10s budget so a stalled local endpoint still cannot hold a tool call open. Both budgets are overridable through `OMNIROUTE_MCP_FETCH_TIMEOUT_MS` and `OMNIROUTE_MCP_UPSTREAM_TIMEOUT_MS`, replacing the reported workaround of patching the compiled `dist/.build/next/server/chunks/*.js`; a malformed or non-positive override falls back to the default rather than disabling the timeout diff --git a/changelog.d/fixes/10862-sync-models-degraded-cached-catalog.md b/changelog.d/fixes/10862-sync-models-degraded-cached-catalog.md deleted file mode 100644 index fd6f7d3f04..0000000000 --- a/changelog.d/fixes/10862-sync-models-degraded-cached-catalog.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** importing models with an expired API key now surfaces the credential error instead of reporting "No new models were added". The Import button posts to `/api/providers/{id}/sync-models`, which self-fetches the models route; that route does not fail on an upstream 401 but degrades to a catalog it already has, preferring the cache and using the local catalog only when there is no cache. A provider that imported successfully once therefore has a cache, so an expired key produced `{ source: "cache", warning: "Models probe failed (401) — using cached catalog" }` with HTTP 200 — and the #5460/#5465 degradation guard only recognised the `local_catalog` branch, so model-sync accepted it as a successful discovery, found every cached model already imported, and returned the empty-diff result. Retest does not go through this path, which is why it failed correctly and made the import look like a genuine "nothing to do". The existing rule — a degraded discovery must not be persisted as the synced catalog — is now applied to the branch it missed rather than special-casing 401/403, discriminating on the warning the fallback builder always attaches (an ordinary non-refresh cache hit attaches none, and model-sync always requests `refresh=true`). `isDegradedLocalCatalog` keeps its exact meaning and its existing tests diff --git a/changelog.d/fixes/10866-combo-empty-models.md b/changelog.d/fixes/10866-combo-empty-models.md deleted file mode 100644 index e71092d5d4..0000000000 --- a/changelog.d/fixes/10866-combo-empty-models.md +++ /dev/null @@ -1 +0,0 @@ -- fix(api): reject a combo update that removes every model, and store the copilot's combo targets where the router reads them (#10866) diff --git a/changelog.d/fixes/10868-proxy-echo-ipv4-fallback.md b/changelog.d/fixes/10868-proxy-echo-ipv4-fallback.md deleted file mode 100644 index 91f4e717e8..0000000000 --- a/changelog.d/fixes/10868-proxy-echo-ipv4-fallback.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(proxy):** proxy "Test connection" no longer reports an IPv4-only SOCKS5/SSH proxy as dead. #1255 moved every egress probe from `api.ipify.org` to `api64.ipify.org` so proxies with IPv6 egress could be tested, but `api64` is IPv6-first: a tunnel with no IPv6 route has nothing to connect to, so the probe hung until the caller's deadline and a proxy that was carrying live LLM traffic came back as a failure. Swapping the target to `api4` fixes that case and re-breaks the one #1255 fixed, so the probe now tries the targets in order instead — `api64` first, so a proxy with working IPv6 answers on the first attempt and keeps the exact behaviour #1255 introduced, including which of its addresses is reported (the egress IP is used as an identity to detect accounts of one rotation group sharing an address, so the attempts are sequential rather than raced). The attempts split the budget each call site already enforced, so no probe can take longer than it could before, and each attempt gets its own `AbortController` so exhausting the budget on an unreachable target does not abort the next one. `OMNIROUTE_PROXY_ECHO_URL` pins a single target — including a self-hosted echo — replacing the workaround of rewriting the compiled bundle after every upgrade. The relay branch of the test route still targets `api64` through `x-relay-target`, since that request egresses from the relay worker rather than the operator's tunnel diff --git a/changelog.d/fixes/10870-cli-env-collision.md b/changelog.d/fixes/10870-cli-env-collision.md deleted file mode 100644 index 95a428ba08..0000000000 --- a/changelog.d/fixes/10870-cli-env-collision.md +++ /dev/null @@ -1 +0,0 @@ -- fix(cli): warn when a .env line never takes effect, and stop swallowing an unreadable .env (#10870) diff --git a/changelog.d/fixes/10873-mimocode-retirement-state-cleanup.md b/changelog.d/fixes/10873-mimocode-retirement-state-cleanup.md deleted file mode 100644 index 44443eac6c..0000000000 --- a/changelog.d/fixes/10873-mimocode-retirement-state-cleanup.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(db):** Remove stale MiMoCode provider configuration, including the legacy `mcode` alias, left after provider retirement while preserving historical usage and call logs ([#10873](https://github.com/diegosouzapw/OmniRoute/pull/10873)) — thanks @Zartharas diff --git a/changelog.d/fixes/10877-quota-alias-fetcher-lookup-gap.md b/changelog.d/fixes/10877-quota-alias-fetcher-lookup-gap.md deleted file mode 100644 index 9501c6dad2..0000000000 --- a/changelog.d/fixes/10877-quota-alias-fetcher-lookup-gap.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(sse):** `getResetAwareProvider()` and the auto-combo quota lookup in `combo.ts` now canonicalize the provider id via `resolveProviderId()` before calling `getQuotaFetcher()`, so a fetcher registered under a provider's canonical id (e.g. `ollama-cloud`, `codex`) is found for combo targets stored under an alias spelling (e.g. `ollamacloud`, `cx`) instead of silently degrading reset-aware/reset-window/auto quota-aware routing to plain priority ordering (#10877) diff --git a/changelog.d/fixes/10878-unsupported-validation-probes-neutral.md b/changelog.d/fixes/10878-unsupported-validation-probes-neutral.md deleted file mode 100644 index 1fc7c01933..0000000000 --- a/changelog.d/fixes/10878-unsupported-validation-probes-neutral.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(provider-health):** Keep unsupported 404/405 validation probes neutral so they do not poison stored credential health or scheduler failure state, while still honoring per-connection health-check pacing ([#10878](https://github.com/diegosouzapw/OmniRoute/pull/10878)) — thanks @Zartharas diff --git a/changelog.d/fixes/10882-antigravity-gemini37-flash-tiers.md b/changelog.d/fixes/10882-antigravity-gemini37-flash-tiers.md deleted file mode 100644 index b1ff4bbf5a..0000000000 --- a/changelog.d/fixes/10882-antigravity-gemini37-flash-tiers.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(antigravity):** map Gemini 3.7 Flash tier ids (`gemini-3.7-flash-high/medium/low`, bare `gemini-3.7-flash`) to the upstream `gemini-3.7-flash-tiered` model id Google's Cloud Code endpoint expects, and configure per-tier thinking budgets ([#10882](https://github.com/diegosouzapw/OmniRoute/pull/10882)) — thanks @adevwithpurpose diff --git a/changelog.d/fixes/10887-memory-mcp-tools.md b/changelog.d/fixes/10887-memory-mcp-tools.md deleted file mode 100644 index 8dc02db1d9..0000000000 --- a/changelog.d/fixes/10887-memory-mcp-tools.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(memory):** enable agent memory save/update via MCP tools (`memory_save`/`update`/`search`/`delete` builtins with per-provider schemas, `apiKeyId` optional with caller-principal fallback) and gate server-side memory builtin injection to non-stream requests only ([#10887](https://github.com/diegosouzapw/OmniRoute/pull/10887)) — thanks @Egorich-print diff --git a/changelog.d/fixes/10902-pplx-search-hint-optin.md b/changelog.d/fixes/10902-pplx-search-hint-optin.md deleted file mode 100644 index 233fb61f19..0000000000 --- a/changelog.d/fixes/10902-pplx-search-hint-optin.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(perplexity-web):** make the built-in-search hint appended to every system message opt-in via `OMNIROUTE_PPLX_SEARCH_HINT` (off by default) — Perplexity's answer engine searches anyway, and the hint leaked into replies as meta-commentary for coding clients ([#10902](https://github.com/diegosouzapw/OmniRoute/pull/10902), extracted from [#8634](https://github.com/diegosouzapw/OmniRoute/pull/8634)) — thanks @danscMax diff --git a/changelog.d/fixes/10903-loopback-gate-memory-success.md b/changelog.d/fixes/10903-loopback-gate-memory-success.md deleted file mode 100644 index 25720ba1a8..0000000000 --- a/changelog.d/fixes/10903-loopback-gate-memory-success.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** the loopback readiness gate no longer memorizes a failed probe — the next caller after 30s starts a fresh probe, and a readiness failure is logged once per probe instead of once per caller ([#10903](https://github.com/diegosouzapw/OmniRoute/pull/10903)) diff --git a/changelog.d/fixes/10935-cloudflare-relay-path-guard.md b/changelog.d/fixes/10935-cloudflare-relay-path-guard.md deleted file mode 100644 index 0799cdc52d..0000000000 --- a/changelog.d/fixes/10935-cloudflare-relay-path-guard.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(relay):** the Cloudflare proxy-relay worker now resolves `x-relay-path` through the shared `resolveRelayTarget()` guard instead of concatenating it onto the validated target. PR #4643 and its follow-up applied that guard to the Deno and Vercel workers; the Cloudflare generator, ported separately from upstream `decolua/9router` PR #1360, kept `fetch(targetBase + relayPath)`. Validating `x-relay-target` and then concatenating is not sufficient — the path re-points the request past the host that was just checked, through userinfo (`/x@evil.com`), a backslash (`\evil.com`), or a protocol-relative path (`//evil.com/x`). The guard is embedded verbatim under a literal `const resolveRelayTarget =` binding so the hardcoded call site still resolves when the SWC-minified standalone build mangles the source function's own name (#6149), and the new regression test pins that property for this worker by renaming the embedded function and re-evaluating the emitted source. The auth check and the private/loopback target guard are unchanged diff --git a/changelog.d/fixes/10936-standalone-server-cjs-esm-scope.md b/changelog.d/fixes/10936-standalone-server-cjs-esm-scope.md deleted file mode 100644 index 824f7df647..0000000000 --- a/changelog.d/fixes/10936-standalone-server-cjs-esm-scope.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(build):** the `next` Docker image no longer crashes on boot with `ReferenceError: require is not defined in ES module scope`. The standalone `server.js` is CommonJS, but the `postbuild` colocate step was re-adding `"type":"module"` to the standalone root `package.json` (undoing `assembleStandalone`'s strip) to make its ESM worker bundles load. The `type:module` scope is now written per-worker-directory instead of on the root, so `server.js` stays CommonJS while the workers stay ESM ([#10936](https://github.com/diegosouzapw/OmniRoute/pull/10936), fixes [#10933](https://github.com/diegosouzapw/OmniRoute/issues/10933)) — thanks @arminanton diff --git a/changelog.d/fixes/10940-opencode-limit-output.md b/changelog.d/fixes/10940-opencode-limit-output.md deleted file mode 100644 index 9af54a2046..0000000000 --- a/changelog.d/fixes/10940-opencode-limit-output.md +++ /dev/null @@ -1 +0,0 @@ -- fix(cli): always emit limit.output in generated OpenCode config so schema validation passes for metadata-less models (#10940) diff --git a/changelog.d/fixes/10941-relay-private-host-guard.md b/changelog.d/fixes/10941-relay-private-host-guard.md deleted file mode 100644 index 53d3aedeec..0000000000 --- a/changelog.d/fixes/10941-relay-private-host-guard.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(relay):** the private/loopback guard the three proxy-relay workers embed no longer misses four host spellings, and now lives in one place instead of three byte-identical inline copies. Driving `new URL(target).hostname` the way the workers do, the previous guard allowed `::` (the unspecified address, which reaches a service bound to the IPv6 loopback), `localhost.` (the FQDN root dot defeated the exact match and every `.localhost`/`.local`/`.internal` suffix rule, so `svc.internal.` slipped too), `::127.0.0.1` (the deprecated IPv4-compatible form — only `::ffff:` was checked), and `feb0::1` (link-local is `fe80::/10`, spanning `fe80`–`febf`, but only the literal `fe80:` spelling matched). The policy moved to `src/lib/proxyRelay/privateHostname.ts` and is embedded verbatim via `Function#toString` under a literal const name, the same mechanism `resolveRelayTarget` already uses for these workers, so a minified standalone build cannot break the call site (#6149). Nothing previously blocked is now allowed. Severity is low — reaching a worker needs the `x-relay-auth` secret and these are edge runtimes where loopback has nothing listening — but the suffix-rule bypass held regardless of runtime diff --git a/changelog.d/fixes/10945-least-used-rotation.md b/changelog.d/fixes/10945-least-used-rotation.md deleted file mode 100644 index 36b23951b2..0000000000 --- a/changelog.d/fixes/10945-least-used-rotation.md +++ /dev/null @@ -1 +0,0 @@ -- **Account rotation:** make `fallbackStrategy: "least-used"` actually rotate. The strategy sorts on `lastUsedAt` but never wrote it — only the round-robin branch committed — so on a pool where every `last_used_at` was still `NULL` the tie-break fell through to `priority` and returned the same connection on every dispatch ([#10945](https://github.com/diegosouzapw/OmniRoute/issues/10945)). diff --git a/changelog.d/fixes/10947-windows-updater-artifact-name.md b/changelog.d/fixes/10947-windows-updater-artifact-name.md deleted file mode 100644 index 10c90a216d..0000000000 --- a/changelog.d/fixes/10947-windows-updater-artifact-name.md +++ /dev/null @@ -1 +0,0 @@ -- **Desktop auto-update (Windows):** stop the in-app updater 404ing on every release. NSIS used electron-builder's default artifact name, whose spaces GitHub rewrites to `.` on upload while `latest.yml` keeps `-`, so the manifest pointed at `OmniRoute-Setup-X.Y.Z.exe` while the published asset was `OmniRoute.Setup.X.Y.Z.exe`. The name is now set explicitly to the dot form the asset already has, so nothing published changes name ([#10947](https://github.com/diegosouzapw/OmniRoute/issues/10947)). diff --git a/changelog.d/fixes/10949-mixed-reasoning-plaintext.md b/changelog.d/fixes/10949-mixed-reasoning-plaintext.md deleted file mode 100644 index 05a055ec53..0000000000 --- a/changelog.d/fixes/10949-mixed-reasoning-plaintext.md +++ /dev/null @@ -1 +0,0 @@ -- Preserve explicit plaintext reasoning when a Responses reasoning item also carries opaque provider state (rare OpenCode Go `deepseek-v4-flash` responses). Mixed plaintext + opaque input is projected onto the target transport: plaintext targets keep portable text, opaque targets keep provider state. Opaque-only reasoning is dropped when the selected target cannot replay it, allowing cross-model conversations to continue. (#10949, #10959) diff --git a/changelog.d/fixes/10953-preserve-provider-effort-tiers.md b/changelog.d/fixes/10953-preserve-provider-effort-tiers.md deleted file mode 100644 index d509aac61b..0000000000 --- a/changelog.d/fixes/10953-preserve-provider-effort-tiers.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(catalog):** preserve provider-declared reasoning effort tiers instead of replacing them with generic defaults ([#10953](https://github.com/diegosouzapw/OmniRoute/pull/10953)) — thanks @xz-dev diff --git a/changelog.d/fixes/10954-combo-create-models.md b/changelog.d/fixes/10954-combo-create-models.md deleted file mode 100644 index 0a0638bcea..0000000000 --- a/changelog.d/fixes/10954-combo-create-models.md +++ /dev/null @@ -1 +0,0 @@ -- fix(cli): combo create accepts --models and no longer creates empty combos (#10954) diff --git a/changelog.d/fixes/10955-cli-ref-params.md b/changelog.d/fixes/10955-cli-ref-params.md deleted file mode 100644 index 9497b4129e..0000000000 --- a/changelog.d/fixes/10955-cli-ref-params.md +++ /dev/null @@ -1 +0,0 @@ -- fix(cli): resolve $ref path params and add PATCH combos requestBody in generated API commands (#10955) diff --git a/changelog.d/fixes/10959-single-target-reasoning-fallback.md b/changelog.d/fixes/10959-single-target-reasoning-fallback.md deleted file mode 100644 index eb6e9c1903..0000000000 --- a/changelog.d/fixes/10959-single-target-reasoning-fallback.md +++ /dev/null @@ -1 +0,0 @@ -- fix(sse): default single-target incompatible reasoning to drop for agentic replay — single-target requests to opaque reasoning targets now gracefully strip incompatible plaintext reasoning history instead of returning HTTP 400, matching combo default behavior while preserving operator and per-request overrides ([#10959](https://github.com/diegosouzapw/OmniRoute/issues/10959)) diff --git a/changelog.d/fixes/10967-10966-combo-diag-recovery.md b/changelog.d/fixes/10967-10966-combo-diag-recovery.md deleted file mode 100644 index e962b14981..0000000000 --- a/changelog.d/fixes/10967-10966-combo-diag-recovery.md +++ /dev/null @@ -1,2 +0,0 @@ -- fix(sse): combo diagnostics no longer truncate `exhausted_connection` entries to a hardcoded `provider: "unknown"` with the provider prefix eaten by an 8-char slice — the real provider id is preserved and only the connection id is truncated (#10967) -- fix(sse): combo terminal failures caused entirely by quota/account-balance exhaustion (including a durable HTTP 403 `insufficient_quota` / `AUTHZ_INSUFFICIENT_BALANCE`) now stamp a stable `quota_exhausted` diagnostics reason with a `switch-combo` recovery hint instead of the misleading default `retry` action (#10966) diff --git a/changelog.d/fixes/10976-skip-default-searxng.md b/changelog.d/fixes/10976-skip-default-searxng.md deleted file mode 100644 index a317979b4a..0000000000 --- a/changelog.d/fixes/10976-skip-default-searxng.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(search):** skip catalog-default SearXNG `http://localhost:8888/search` so Docker/K8s search does not ECONNREFUSED then 502 into the next provider ([#10976](https://github.com/diegosouzapw/OmniRoute/issues/10976)) diff --git a/changelog.d/fixes/10986-reasoning-only-content.md b/changelog.d/fixes/10986-reasoning-only-content.md deleted file mode 100644 index 0d293482bd..0000000000 --- a/changelog.d/fixes/10986-reasoning-only-content.md +++ /dev/null @@ -1 +0,0 @@ -- fix(command-code): surface reasoning-only output as content when a model emits no text-delta (#10986) \ No newline at end of file diff --git a/changelog.d/fixes/10988-release-v3850-quality-gates.md b/changelog.d/fixes/10988-release-v3850-quality-gates.md deleted file mode 100644 index 283b30b836..0000000000 --- a/changelog.d/fixes/10988-release-v3850-quality-gates.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(ci):** clear inherited `release/v3.8.50` quality-gate reds on the X Search PR: drop the stale `copilot-m365-web.ts:330` public-creds allowlist, document six missing env vars, register four covering Stryker tap tests, prune leftover ESLint suppressions, replace the phantom `@/lib/db/connections` Utilization import with `getProviderConnectionById`, and fix open-sse/dashboard typecheck regressions in freebuff, browser-backed chat, auth, health matrix, and Monaco ([#10988](https://github.com/diegosouzapw/OmniRoute/pull/10988)). diff --git a/changelog.d/fixes/10988-release-v3850-unit-shards.md b/changelog.d/fixes/10988-release-v3850-unit-shards.md deleted file mode 100644 index 139266c990..0000000000 --- a/changelog.d/fixes/10988-release-v3850-unit-shards.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(ci):** clear remaining `release/v3.8.50` unit-shard reds on the X Search PR: pin `onnxruntime-node` to the transformers 1.24.3 copy, rebaseline OpenAPI coverage, sync goldens/i18n, honor eye-hidden no-auth models across provider aliases, await rejected-request call-log writes, absorb catalog event-loop shard contention in #9147, and align inherited tests with advisory context estimates, #10501 combo terminal-status aggregation, and current catalog/auth behavior ([#10988](https://github.com/diegosouzapw/OmniRoute/pull/10988)). diff --git a/changelog.d/fixes/10990-v0-vercel-web-static-catalog.md b/changelog.d/fixes/10990-v0-vercel-web-static-catalog.md deleted file mode 100644 index 9d56721208..0000000000 --- a/changelog.d/fixes/10990-v0-vercel-web-static-catalog.md +++ /dev/null @@ -1 +0,0 @@ -- **Static model catalog for v0-vercel-web:** seed a static catalog for the v0-vercel-web web-cookie provider (v0-1.0-md, v0-1.5-lg, v0-1.5-md) so its dashboard "Available Models" / "Import from /models" UI serves a usable list instead of falling through to the route's 400 "does not support models listing" ([#10990](https://github.com/diegosouzapw/OmniRoute/issues/10990)). \ No newline at end of file diff --git a/changelog.d/fixes/10997-blackbox-deprecation.md b/changelog.d/fixes/10997-blackbox-deprecation.md deleted file mode 100644 index 74ac191526..0000000000 --- a/changelog.d/fixes/10997-blackbox-deprecation.md +++ /dev/null @@ -1 +0,0 @@ -- fix(providers): mark the blackbox provider deprecated — api.blackbox.ai returns HTTP 404 on every path variant (sweep 2026-08-21), so the public inference surface is dead and the catalog entry now carries a deprecation notice. ([#10997](https://github.com/diegosouzapw/OmniRoute/issues/10997)) \ No newline at end of file diff --git a/changelog.d/fixes/11002-dify-key-validation.md b/changelog.d/fixes/11002-dify-key-validation.md deleted file mode 100644 index 6574714c9b..0000000000 --- a/changelog.d/fixes/11002-dify-key-validation.md +++ /dev/null @@ -1 +0,0 @@ -- fix(providers): validate Dify keys against its native /v1/chat-messages endpoint (#11002) \ No newline at end of file diff --git a/changelog.d/fixes/11008-account-rotation-eviction.md b/changelog.d/fixes/11008-account-rotation-eviction.md deleted file mode 100644 index 4855dde6f2..0000000000 --- a/changelog.d/fixes/11008-account-rotation-eviction.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(accounts):** `markCooldown` now carries the failure origin (`transient` vs `terminal`) — transient 429/network only cools down, repeated terminal failures evict and are skipped by `pickAccount` until a success or operator clear ([#11008](https://github.com/diegosouzapw/OmniRoute/pull/11008)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/11009-terminal-status-origin.md b/changelog.d/fixes/11009-terminal-status-origin.md deleted file mode 100644 index f0ab24edaf..0000000000 --- a/changelog.d/fixes/11009-terminal-status-origin.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** route terminal `testStatus` writes (`banned`, `deactivated`, `credits_exhausted`) through a single origin-aware passage — probe failures are recorded but never deactivate the connection ([#11009](https://github.com/diegosouzapw/OmniRoute/pull/11009)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/11014-codex-drop-default-on.md b/changelog.d/fixes/11014-codex-drop-default-on.md deleted file mode 100644 index 0e5a8f1129..0000000000 --- a/changelog.d/fixes/11014-codex-drop-default-on.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(codex):** drop non-standard `codex.*` SSE events by default so OpenAI SDK / Codex CLI `/v1/responses` clients are not 502'd by `event: codex.rate_limits` ([#11014](https://github.com/diegosouzapw/OmniRoute/issues/11014)) — thanks @RaviTharuma diff --git a/changelog.d/fixes/11015-shutdown-track-sse.md b/changelog.d/fixes/11015-shutdown-track-sse.md deleted file mode 100644 index 1ed99b3669..0000000000 --- a/changelog.d/fixes/11015-shutdown-track-sse.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(resilience):** count heavyweight `/v1` admission leases in the SIGTERM drain and send `Retry-After` on shutdown 503s so Recreate no longer looks like an empty 502 ([#11015](https://github.com/diegosouzapw/OmniRoute/issues/11015)) — thanks @RaviTharuma diff --git a/changelog.d/fixes/11016-cred-health-disable-log.md b/changelog.d/fixes/11016-cred-health-disable-log.md deleted file mode 100644 index 37a9715f41..0000000000 --- a/changelog.d/fixes/11016-cred-health-disable-log.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(startup):** log `Credential health scheduler disabled` when `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` is set instead of lying with `started` ([#11016](https://github.com/diegosouzapw/OmniRoute/issues/11016)) — thanks @RaviTharuma diff --git a/changelog.d/fixes/11017-rate-limit-docs.md b/changelog.d/fixes/11017-rate-limit-docs.md deleted file mode 100644 index fc92469bd6..0000000000 --- a/changelog.d/fixes/11017-rate-limit-docs.md +++ /dev/null @@ -1 +0,0 @@ -- **docs(api-keys):** document that unset `DEFAULT_RATE_LIMIT_PER_DAY` is unlimited (#2289), not a hidden 1000/day cap ([#11017](https://github.com/diegosouzapw/OmniRoute/issues/11017)) — thanks @RaviTharuma diff --git a/changelog.d/fixes/11050-remove-ghost-webhook-events.md b/changelog.d/fixes/11050-remove-ghost-webhook-events.md deleted file mode 100644 index 6278ee6c0a..0000000000 --- a/changelog.d/fixes/11050-remove-ghost-webhook-events.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(webhooks):** remove 3 declared-but-never-emitted events (`provider.error`, `provider.recovered`, `combo.switched`) from `WebhookEvent` — catalog now `request.completed | request.failed | quota.exceeded | test.ping`; `POST /api/webhooks` and `PUT /api/webhooks/[id]` reject ghost values with 400; OpenAPI webhook description updated across 43 locales ([11050](https://github.com/diegosouzapw/OmniRoute/pull/11050)) diff --git a/changelog.d/fixes/11060-perplexity-filter.md b/changelog.d/fixes/11060-perplexity-filter.md deleted file mode 100644 index c221d3ccab..0000000000 --- a/changelog.d/fixes/11060-perplexity-filter.md +++ /dev/null @@ -1 +0,0 @@ -- fix(providers): filter Perplexity model import to the Sonar family so Agent-API catalog ids stop surfacing as routable chat models (#11060) diff --git a/changelog.d/fixes/11085-claude-code-tool-name-casing.md b/changelog.d/fixes/11085-claude-code-tool-name-casing.md deleted file mode 100644 index 5ad424c141..0000000000 --- a/changelog.d/fixes/11085-claude-code-tool-name-casing.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(claude):** restore canonical tool names (`bash` → `Bash`, `croncreate` → `CronCreate`) on non-streaming OpenAI→Claude conversion and through identity-echo alias maps, so Claude Code stops rejecting tool calls with "No such tool available" ([#11085](https://github.com/diegosouzapw/OmniRoute/pull/11085)) — thanks @linhdmn diff --git a/changelog.d/fixes/11089-chat-routing-synced-inventory.md b/changelog.d/fixes/11089-chat-routing-synced-inventory.md deleted file mode 100644 index 922b96a659..0000000000 --- a/changelog.d/fixes/11089-chat-routing-synced-inventory.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(resilience):** filter chat connection selection by each connection's *synced* model inventory on multi-host self-hosted providers (`ollama-local`, `lm-studio`, `vllm`, …), so a request for a model only one host advertises is pinned to that host instead of failing over onto a host that never had it ([#11089](https://github.com/diegosouzapw/OmniRoute/issues/11089)) diff --git a/changelog.d/fixes/11095-termux-onnx.md b/changelog.d/fixes/11095-termux-onnx.md deleted file mode 100644 index 8c26803710..0000000000 --- a/changelog.d/fixes/11095-termux-onnx.md +++ /dev/null @@ -1 +0,0 @@ -- fix(install): make the ONNX dependency chain optional so Termux/Android installs succeed again (#11095) diff --git a/changelog.d/fixes/11101-reject-silent-validation.md b/changelog.d/fixes/11101-reject-silent-validation.md deleted file mode 100644 index 04b2a67d5a..0000000000 --- a/changelog.d/fixes/11101-reject-silent-validation.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** Reject silent validation degradation on provider connection patch — unknown `rateLimitOverrides` keys (e.g. a typo'd `tpm`) and empty/non-numeric values now return `400` with the rejected key list instead of being silently dropped ([#11101](https://github.com/diegosouzapw/OmniRoute/pull/11101)) diff --git a/changelog.d/fixes/11102-combo-suggestion-count.md b/changelog.d/fixes/11102-combo-suggestion-count.md deleted file mode 100644 index 3cbf6f11d3..0000000000 --- a/changelog.d/fixes/11102-combo-suggestion-count.md +++ /dev/null @@ -1 +0,0 @@ -- **Autopilot suggestion counter:** the combo health autopilot summary now reports `suggestionCount` (the real number of suggested actions across all issues) instead of conflating it with link counts, while keeping `actionableCount` as a deprecated alias for backward compatibility. The `run_combo_test` action now links to the dashboard with the combo id (`/dashboard/combos?test=`) rather than the read-only API route, so operators can actually trigger a test from the UI ([#11102](https://github.com/diegosouzapw/OmniRoute/pull/11102)). diff --git a/changelog.d/fixes/11103-persist-config-audit-log.md b/changelog.d/fixes/11103-persist-config-audit-log.md deleted file mode 100644 index aeb53b1781..0000000000 --- a/changelog.d/fixes/11103-persist-config-audit-log.md +++ /dev/null @@ -1 +0,0 @@ -- **Config audit persistence:** persist the configuration audit trail to SQLite (`config_audit_log`) instead of an in-memory buffer capped at 1000 volatile entries, and bound its growth with `cleanupConfigAudit()` driven by the `retention.configAudit` setting (default 30 days), wired into `runAutoCleanup` ([#11103](https://github.com/diegosouzapw/OmniRoute/pull/11103)). diff --git a/changelog.d/fixes/11109-stream-recovery-toolcall.md b/changelog.d/fixes/11109-stream-recovery-toolcall.md deleted file mode 100644 index 04a43382e4..0000000000 --- a/changelog.d/fixes/11109-stream-recovery-toolcall.md +++ /dev/null @@ -1 +0,0 @@ -- fix(sse): resume mid-stream recovery after a _completed_ tool call — `finish_reason: "tool_calls"` is now tracked per-call instead of as a general terminal marker, so truncation of trailing prose after a fully-delivered tool call is recoverable while in-flight calls stay blocked ([#11109](https://github.com/diegosouzapw/OmniRoute/pull/11109)) diff --git a/changelog.d/fixes/11116-reasoning-effort-capability-discovery.md b/changelog.d/fixes/11116-reasoning-effort-capability-discovery.md deleted file mode 100644 index fbc4dfe694..0000000000 --- a/changelog.d/fixes/11116-reasoning-effort-capability-discovery.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** `reasoning_effort` now learns the accepted values from a provider's own 400/422 response and clamps to the highest one instead of forwarding an unsupported `xhigh`/`max` (or a hardcoded `"high"` fallback) — fixes custom OpenAI-compatible connections and registered providers with no reasoning metadata ([#11116](https://github.com/diegosouzapw/OmniRoute/pull/11116)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/11144-responses-parallel-tool-calls-index.md b/changelog.d/fixes/11144-responses-parallel-tool-calls-index.md deleted file mode 100644 index 35ba19b279..0000000000 --- a/changelog.d/fixes/11144-responses-parallel-tool-calls-index.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(sse):** parallel `function_call` items in a Responses API stream (e.g. several tool calls dispatched in the same turn) now each get a stable, distinct `index`/`id` when translated to Chat Completions streaming deltas, instead of colliding on index 0 and tripping strict stream parsers with `Expected 'id' to be a string.` ([#11144](https://github.com/diegosouzapw/OmniRoute/pull/11144)) diff --git a/changelog.d/fixes/11149-opencode-go-flat-rate.md b/changelog.d/fixes/11149-opencode-go-flat-rate.md deleted file mode 100644 index 7aa63ad455..0000000000 --- a/changelog.d/fixes/11149-opencode-go-flat-rate.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(analytics):** `opencode-go` is now classified as a flat-rate subscription, so cost analytics shows $0 for it instead of billing every call at the underlying model’s metered rate — it resells GLM, Kimi, Grok, DeepSeek, MiniMax, Qwen and GPT-5.x under one flat monthly fee, which made the overstatement large rather than marginal ([#11149](https://github.com/diegosouzapw/OmniRoute/pull/11149)) — thanks @electrumguy diff --git a/changelog.d/fixes/11154-provider-registry-node-net-bundle.md b/changelog.d/fixes/11154-provider-registry-node-net-bundle.md deleted file mode 100644 index b30d9e1392..0000000000 --- a/changelog.d/fixes/11154-provider-registry-node-net-bundle.md +++ /dev/null @@ -1 +0,0 @@ -- fix(dashboard): keep `open-sse/config/providerRegistry.ts` free of `node:net` so the provider detail client bundle builds again — the host classification moved to a platform-free `src/shared/network/privateHost.ts` with a pure-JS `isIP` equivalent, leaving the #11122 routing behaviour unchanged (#11154) diff --git a/changelog.d/fixes/11162-combo-create-requires-model.md b/changelog.d/fixes/11162-combo-create-requires-model.md deleted file mode 100644 index 228e6a9b20..0000000000 --- a/changelog.d/fixes/11162-combo-create-requires-model.md +++ /dev/null @@ -1 +0,0 @@ -- **Combo create:** creating a routing combo without any model is now refused (`400`) — the CLI requires `--models`/`--model` on `combo create`, matching the dashboard which already rejected empty combos. diff --git a/changelog.d/fixes/11165-shared-registry-passthrough-model-lockout.md b/changelog.d/fixes/11165-shared-registry-passthrough-model-lockout.md deleted file mode 100644 index eabe67cb09..0000000000 --- a/changelog.d/fixes/11165-shared-registry-passthrough-model-lockout.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(resilience):** a missing-model `404` on a provider that declares `passthroughModels: true` in the shared registry (novita, uncloseai, orcarouter and 37 others) now locks out only that model instead of cooling the entire connection — `hasPerModelQuota()` previously read only the open-sse registry and the local/self-hosted families ([#11165](https://github.com/diegosouzapw/OmniRoute/pull/11165)) — thanks @yourspraveen diff --git a/changelog.d/fixes/11180-keyless-custom-provider-auto-pool.md b/changelog.d/fixes/11180-keyless-custom-provider-auto-pool.md deleted file mode 100644 index c533feae54..0000000000 --- a/changelog.d/fixes/11180-keyless-custom-provider-auto-pool.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(routing):** a custom `openai-compatible-*` / `anthropic-compatible-*` connection pointing at a keyless self-hosted backend (llama.cpp, Ollama, vLLM started without an API key) now stays in the `auto/*` candidate pool instead of being silently dropped by the credential gate — for those IDs "no credential" is the normal configuration, not an unconfigured connection ([#11180](https://github.com/diegosouzapw/OmniRoute/pull/11180)) — thanks @marcs7 diff --git a/changelog.d/fixes/11181-lkgp-enabled-context.md b/changelog.d/fixes/11181-lkgp-enabled-context.md deleted file mode 100644 index d1c0cde5a3..0000000000 --- a/changelog.d/fixes/11181-lkgp-enabled-context.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(routing):** the Routing tab's "last known good provider" toggle now actually takes effect — `lkgpEnabled` was persisted and the `lkgp` strategy guarded on it, but the setting was never forwarded into the `RoutingContext` built in `resolveAutoStrategyOrder()`, so `context.lkgpEnabled` was always `undefined` and the off-switch was unreachable ([#11181](https://github.com/diegosouzapw/OmniRoute/issues/11181)) diff --git a/changelog.d/fixes/11271-ollama-capability-routing.md b/changelog.d/fixes/11271-ollama-capability-routing.md deleted file mode 100644 index 3846f4f0b9..0000000000 --- a/changelog.d/fixes/11271-ollama-capability-routing.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(ollama):** Ollama Local models are no longer flattened to `chat` at sync time — the synced store persists every advertised capability and chat filtering moves to read time, so `/v1/embeddings` and `/v1/images/generations` stop rejecting models the daemon reports as capable ([#11271](https://github.com/diegosouzapw/OmniRoute/pull/11271)) — thanks @yourspraveen diff --git a/changelog.d/fixes/11297-opencode-subagent-sessionid.md b/changelog.d/fixes/11297-opencode-subagent-sessionid.md deleted file mode 100644 index 37662d584e..0000000000 --- a/changelog.d/fixes/11297-opencode-subagent-sessionid.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(translator):** preserve omitted OpenCode `subagent.sessionID` values — optional default-less plain strings now use the Responses `null = omit` sentinel and are stripped before the client sees the tool call, so Codex/Responses no longer invent filler session IDs ([#11297](https://github.com/diegosouzapw/OmniRoute/pull/11297)) — thanks @ofonseca-pyming diff --git a/changelog.d/fixes/7346-electron-hollow-nested-package-repair.md b/changelog.d/fixes/7346-electron-hollow-nested-package-repair.md deleted file mode 100644 index fd7e61988b..0000000000 --- a/changelog.d/fixes/7346-electron-hollow-nested-package-repair.md +++ /dev/null @@ -1 +0,0 @@ -- fix(cli): repair hollow externalized package dirs in the nested `/node_modules` bundle location too, not just the top-level one, fixing macOS/Linux Electron `ERR_MODULE_NOT_FOUND` on Turbopack-externalized packages (#7346) diff --git a/changelog.d/fixes/7592-electron-cold-restart-native-driver-check.md b/changelog.d/fixes/7592-electron-cold-restart-native-driver-check.md deleted file mode 100644 index e6458879cf..0000000000 --- a/changelog.d/fixes/7592-electron-cold-restart-native-driver-check.md +++ /dev/null @@ -1 +0,0 @@ -- **Electron packaged smoke test:** add a cold-restart mode (`ELECTRON_SMOKE_COLD_RESTART=1`, wired blocking on the Linux release leg) that relaunches the packaged app against its own persisted `DATA_DIR` and asserts a native SQLite driver was selected instead of the sql.js WASM fallback, closing the regression-test gap flagged in the stale-ABI `better-sqlite3` investigation ([#7592](https://github.com/diegosouzapw/OmniRoute/issues/7592)). diff --git a/changelog.d/fixes/7764-quota-window-order.md b/changelog.d/fixes/7764-quota-window-order.md deleted file mode 100644 index 297131ed28..0000000000 --- a/changelog.d/fixes/7764-quota-window-order.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(usage):** keep session/weekly/monthly quota windows in chronological order on every provider card. The order is now derived from the quota keys themselves instead of a provider whitelist, so Claude, MiniMax, Z.ai and Command Code stop rendering the two bars in opposite positions across sibling accounts ([#7764](https://github.com/diegosouzapw/OmniRoute/issues/7764)) diff --git a/changelog.d/fixes/8307-codex-image-account-fallback-retryable.md b/changelog.d/fixes/8307-codex-image-account-fallback-retryable.md deleted file mode 100644 index bd4a70a1ab..0000000000 --- a/changelog.d/fixes/8307-codex-image-account-fallback-retryable.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(images):** retry Codex image generation on a sibling ChatGPT account when the requested model isn't entitled on the current account, instead of failing the request outright ([#8307](https://github.com/diegosouzapw/OmniRoute/pull/8307)). diff --git a/changelog.d/fixes/8864-uncloseai-noauth.md b/changelog.d/fixes/8864-uncloseai-noauth.md deleted file mode 100644 index 8a38e6b836..0000000000 --- a/changelog.d/fixes/8864-uncloseai-noauth.md +++ /dev/null @@ -1 +0,0 @@ -- fix(dashboard): treat UncloseAI as a no-auth provider so the connect form no longer forces a fake API key (#8864) diff --git a/changelog.d/fixes/9013-model-param-filter-save.md b/changelog.d/fixes/9013-model-param-filter-save.md deleted file mode 100644 index d81d7ab179..0000000000 --- a/changelog.d/fixes/9013-model-param-filter-save.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(dashboard):** model-level allowed/blocked param edits now persist when the compatibility popover is closed by clicking outside, and a failed save no longer clears the edit or reports success ([#9013](https://github.com/diegosouzapw/OmniRoute/pull/9013)) diff --git a/changelog.d/fixes/9123-search-provider-local-flag-guard-mismatch.md b/changelog.d/fixes/9123-search-provider-local-flag-guard-mismatch.md deleted file mode 100644 index bc51e12103..0000000000 --- a/changelog.d/fixes/9123-search-provider-local-flag-guard-mismatch.md +++ /dev/null @@ -1 +0,0 @@ -- fix(ssrf): make `getProviderOutboundGuard()` (used for search-provider connection validation, image generation and remote image fetch) honor the local-first default `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` the same way the chat validation guard already does, so a LAN-hosted SearXNG/Brave search provider works with only the LOCAL flag set instead of silently requiring `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` ([#9123](https://github.com/diegosouzapw/OmniRoute/issues/9123)). \ No newline at end of file diff --git a/changelog.d/fixes/9144-github-copilot-file-reference-compression-corruption.md b/changelog.d/fixes/9144-github-copilot-file-reference-compression-corruption.md deleted file mode 100644 index 6f48adeab5..0000000000 --- a/changelog.d/fixes/9144-github-copilot-file-reference-compression-corruption.md +++ /dev/null @@ -1 +0,0 @@ -- fix(compression): preserve unfenced raw code (e.g. Copilot #file references) from Caveman's prose recapitalization/whitespace cleanup, which was corrupting keyword casing and indentation (#9144) diff --git a/changelog.d/fixes/9147-catalog-eventloop-yield.md b/changelog.d/fixes/9147-catalog-eventloop-yield.md deleted file mode 100644 index 1f27c92b33..0000000000 --- a/changelog.d/fixes/9147-catalog-eventloop-yield.md +++ /dev/null @@ -1 +0,0 @@ -- fix(api): yield the event loop during catalog builds and bulk-load override/hidden-model tables (#9147) \ No newline at end of file diff --git a/changelog.d/fixes/9303-recovery-hint-all-targets-skipped.md b/changelog.d/fixes/9303-recovery-hint-all-targets-skipped.md deleted file mode 100644 index 78649d651f..0000000000 --- a/changelog.d/fixes/9303-recovery-hint-all-targets-skipped.md +++ /dev/null @@ -1 +0,0 @@ -- fix(combo): recovery hint for all_targets_skipped now points at provider quota/availability instead of 'transient, just retry' (#9303) diff --git a/changelog.d/fixes/9617-gemini-uniqueitems-strip.md b/changelog.d/fixes/9617-gemini-uniqueitems-strip.md deleted file mode 100644 index 8e01e17a28..0000000000 --- a/changelog.d/fixes/9617-gemini-uniqueitems-strip.md +++ /dev/null @@ -1 +0,0 @@ -- fix(providers): strip uniqueItems from Gemini tool schemas (Gemini rejects it with 400 'Unknown name uniqueItems') (#9617) diff --git a/changelog.d/fixes/9692-openai-to-claude-tool-images.md b/changelog.d/fixes/9692-openai-to-claude-tool-images.md deleted file mode 100644 index c082d0dbc3..0000000000 --- a/changelog.d/fixes/9692-openai-to-claude-tool-images.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(translator):** convert OpenAI `image_url` blocks nested in `role: "tool"` / `tool_result` content to Claude `image` source blocks so OpenAI-compatible clients (Kimi Code CLI `ReadMediaFile`, and any other tool that returns media) no longer 400 the next Claude-format upstream turn ([#9692](https://github.com/diegosouzapw/OmniRoute/issues/9692)) diff --git a/changelog.d/fixes/9708-codex-same-account-retry.md b/changelog.d/fixes/9708-codex-same-account-retry.md deleted file mode 100644 index 2ccb7fb97f..0000000000 --- a/changelog.d/fixes/9708-codex-same-account-retry.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(resilience):** retry a retryable Codex pre-output 502/503/504/507 once on the same account (2–3s jitter) before cooling the connection, and stop translating that mixed pool into an all-accounts quota `429` ([#9708](https://github.com/diegosouzapw/OmniRoute/issues/9708)) diff --git a/changelog.d/fixes/9763-ratelimit-mintime-floor.md b/changelog.d/fixes/9763-ratelimit-mintime-floor.md deleted file mode 100644 index 2b145f1e1a..0000000000 --- a/changelog.d/fixes/9763-ratelimit-mintime-floor.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(ratelimit):** respect operator `minTimeBetweenRequestsMs` floor when relaxing the limiter on headroom — the adaptive rate-limit learning no longer silently erases a configured minimum gap between requests when the upstream reports plenty of remaining capacity ([#9763](https://github.com/diegosouzapw/OmniRoute/issues/9763)). diff --git a/changelog.d/fixes/9821-mcp-pack-unit-stall.md b/changelog.d/fixes/9821-mcp-pack-unit-stall.md deleted file mode 100644 index c8f677535b..0000000000 --- a/changelog.d/fixes/9821-mcp-pack-unit-stall.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(test):** remove live `npm pack` from MCP files unit test (it stalled concurrent `test:unit` via prepare→husky + monorepo pack walk); keep the static #3578 `files` allowlist + negation guards in unit and fold #3821 pack assertions into `check:pack-artifact` / `check:pack-policy` (already `--ignore-scripts`). diff --git a/changelog.d/fixes/9935-media-playground-masked-bearer.md b/changelog.d/fixes/9935-media-playground-masked-bearer.md deleted file mode 100644 index 2fd123d801..0000000000 --- a/changelog.d/fixes/9935-media-playground-masked-bearer.md +++ /dev/null @@ -1 +0,0 @@ -- fix(dashboard): media mini-playgrounds authenticate via session instead of sending the masked API key as Bearer, fixing 401s under REQUIRE_API_KEY (#9935) diff --git a/changelog.d/fixes/9970-credential-health-search-provider-exclusion.md b/changelog.d/fixes/9970-credential-health-search-provider-exclusion.md deleted file mode 100644 index 04aef65204..0000000000 --- a/changelog.d/fixes/9970-credential-health-search-provider-exclusion.md +++ /dev/null @@ -1 +0,0 @@ -- fix(sse): exclude search providers from credential-health scheduler sweep to stop burning billed API queries (#9970) diff --git a/changelog.d/fixes/PENDING-electron-window-hidden-hostname-bind.md b/changelog.d/fixes/PENDING-electron-window-hidden-hostname-bind.md deleted file mode 100644 index 6a88ba926a..0000000000 --- a/changelog.d/fixes/PENDING-electron-window-hidden-hostname-bind.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(electron):** desktop window stays hidden on Windows because the embedded Next.js server binds to the machine hostname instead of loopback ([#PENDING](https://github.com/diegosouzapw/OmniRoute/pull/PENDING)) diff --git a/changelog.d/fixes/api-manager-empty-combo-allowlist.md b/changelog.d/fixes/api-manager-empty-combo-allowlist.md deleted file mode 100644 index 7180578281..0000000000 --- a/changelog.d/fixes/api-manager-empty-combo-allowlist.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(api-manager):** Allowed Combos can now be restricted to zero entries: **All** is stored explicitly as `combo/*`, while **Restrict** with no selection saves an empty allowlist that denies Combo routes without blocking direct models. Existing keys are migrated to preserve their previous allow-all behavior. diff --git a/changelog.d/fixes/assemble-standalone-cpsync-race.md b/changelog.d/fixes/assemble-standalone-cpsync-race.md deleted file mode 100644 index 82fabbcad6..0000000000 --- a/changelog.d/fixes/assemble-standalone-cpsync-race.md +++ /dev/null @@ -1 +0,0 @@ -- fix(build): tolerate a same-realpath symlink or stale-typed dest in the standalone bundle assembler, fixing non-deterministic `ERR_FS_CP_EINVAL`/`ERR_FS_CP_DIR_TO_NON_DIR` crashes under heavy concurrent build I/O diff --git a/changelog.d/fixes/auto-empty-pool-log-once.md b/changelog.d/fixes/auto-empty-pool-log-once.md deleted file mode 100644 index 90d92ed82f..0000000000 --- a/changelog.d/fixes/auto-empty-pool-log-once.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(auto):** rate-limit `auto/ matched no connected models` warnings to once per minute per label (`open-sse/services/autoCombo/virtualFactory.ts`) diff --git a/changelog.d/fixes/basered-deadcode-opencode-config-dir.md b/changelog.d/fixes/basered-deadcode-opencode-config-dir.md deleted file mode 100644 index 0055420d4f..0000000000 --- a/changelog.d/fixes/basered-deadcode-opencode-config-dir.md +++ /dev/null @@ -1 +0,0 @@ -- fix(cli): drop the orphaned `resolveOpencodeConfigDir` re-export from `cliRuntime` — it lost its last consumer in #10246 and diverged from the canonical resolver by one directory level (#9985) diff --git a/changelog.d/fixes/build-advisory-hosted-runner.md b/changelog.d/fixes/build-advisory-hosted-runner.md deleted file mode 100644 index 4bc5ef5469..0000000000 --- a/changelog.d/fixes/build-advisory-hosted-runner.md +++ /dev/null @@ -1 +0,0 @@ -- fix(ci): make `Build (advisory)` produce a signal again — pinned to a hosted runner with the swap/heap provisioning `Fast Production Build` proves sufficient, and scoped to fork PRs, which are the only ones `build.yml` cannot cover (72 of the last 100 PRs into `release/**`) diff --git a/changelog.d/fixes/catalog-cache-hash-apikey.md b/changelog.d/fixes/catalog-cache-hash-apikey.md deleted file mode 100644 index e815ab1fec..0000000000 --- a/changelog.d/fixes/catalog-cache-hash-apikey.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(api):** hash API keys in the `/v1/models` catalog cache Map key so heap dumps cannot leak bearer tokens (`src/app/api/v1/models/catalogCache.ts`) diff --git a/changelog.d/fixes/catalog-openrouter-gemini-embedding-2.md b/changelog.d/fixes/catalog-openrouter-gemini-embedding-2.md deleted file mode 100644 index 21fd9808e3..0000000000 --- a/changelog.d/fixes/catalog-openrouter-gemini-embedding-2.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** register live OpenRouter Gemini Embedding 2 ids (`google/gemini-embedding-2` and `google/gemini-embedding-2-preview`, 3072-d) in the curated embeddings catalog so `GET /v1/models` and `GET /v1/embeddings` list the ids that already serve — thanks @RaviTharuma diff --git a/changelog.d/fixes/claude-to-gemini-consecutive-roles.md b/changelog.d/fixes/claude-to-gemini-consecutive-roles.md deleted file mode 100644 index 17483dce52..0000000000 --- a/changelog.d/fixes/claude-to-gemini-consecutive-roles.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(translator):** merge consecutive same-role contents in direct Claude to Gemini request translation to prevent upstream HTTP 400 errors diff --git a/changelog.d/fixes/cline-task-id-passthrough.md b/changelog.d/fixes/cline-task-id-passthrough.md deleted file mode 100644 index a6d2ecec57..0000000000 --- a/changelog.d/fixes/cline-task-id-passthrough.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(cline):** Preserve client-supplied Cline task IDs and omit the header when clients provide none, preventing request-scoped proxy IDs from being reported as tasks. diff --git a/changelog.d/fixes/codex-appserver-hardening.md b/changelog.d/fixes/codex-appserver-hardening.md deleted file mode 100644 index b73bc3fc20..0000000000 --- a/changelog.d/fixes/codex-appserver-hardening.md +++ /dev/null @@ -1 +0,0 @@ -- Hardened the Codex app-server transport after the post-merge security review of #11205: approval prompts from the app-server (its own command/file/permission execution — not the harness tool passthrough) are now auto-denied by default, with opt-in auto-approval via `providerSpecificData.codexAppServerAutoApprove` / `OMNIROUTE_CODEX_APPSERVER_AUTO_APPROVE`; the default codex sandbox changed from `danger-full-access` to `workspace-write` (override per connection or env); env-sourced capability tokens are now only sent to env-sourced URLs or operator-local hosts (loopback/RFC1918/link-local/ULA/localhost/single-label LAN names/*.local/*.ts.net/*.internal), so a connection's providerSpecificData URL can no longer exfiltrate the operator's env token; and the `/readyz` health probe no longer follows redirects while carrying the bearer token. diff --git a/changelog.d/fixes/codex-max-context-window.md b/changelog.d/fixes/codex-max-context-window.md deleted file mode 100644 index 391d894e46..0000000000 --- a/changelog.d/fixes/codex-max-context-window.md +++ /dev/null @@ -1 +0,0 @@ -- fix(codex): prefer `max_context_window` over the `context_window` pricing tier as the usable input limit in discovery, and raise the static Codex OAuth catalog to the same usable window so the conservative discovery merge no longer caps live values at the 272K pricing tier diff --git a/changelog.d/fixes/combo-connection-scoped-reasoning-efforts.md b/changelog.d/fixes/combo-connection-scoped-reasoning-efforts.md deleted file mode 100644 index 3a53f1323a..0000000000 --- a/changelog.d/fixes/combo-connection-scoped-reasoning-efforts.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(catalog):** derive combo reasoning-effort tiers from the exact runtime-selectable connection scope, intersecting dynamic, pinned, allowlisted, and compatible provider-node evidence while failing closed on unknown capabilities. diff --git a/changelog.d/fixes/combo-sticky-pin-clear-on-disable.md b/changelog.d/fixes/combo-sticky-pin-clear-on-disable.md deleted file mode 100644 index 17abffb7f7..0000000000 --- a/changelog.d/fixes/combo-sticky-pin-clear-on-disable.md +++ /dev/null @@ -1 +0,0 @@ -- fix(combo): evict in-memory session-stickiness bindings when a combo disables stickiness, so stale pins stop overriding the declared priority order until TTL/restart diff --git a/changelog.d/fixes/command-code-effort-capabilities.md b/changelog.d/fixes/command-code-effort-capabilities.md deleted file mode 100644 index 38057107ef..0000000000 --- a/changelog.d/fixes/command-code-effort-capabilities.md +++ /dev/null @@ -1 +0,0 @@ -- fix(combo): resolve effort-suffixed command-code variants (e.g. `deepseek-v4-flash-max`) to their base model for capability lookups, so tool-bearing combo requests keep the declared priority order instead of reordering behind models with confirmed capabilities diff --git a/changelog.d/fixes/compression-run-telemetry-retention-ms.md b/changelog.d/fixes/compression-run-telemetry-retention-ms.md deleted file mode 100644 index cbaedbe25e..0000000000 --- a/changelog.d/fixes/compression-run-telemetry-retention-ms.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(db):** the `compression_run_telemetry` retention sweep now actually deletes expired rows. Its cutoff was computed in epoch seconds while the column stores epoch milliseconds, so `WHERE timestamp < cutoff` never matched and the table added by #6848 to bound `storage.sqlite` growth was unbounded in practice. Same unit mismatch as #9625, which corrected the sibling `domain_cost_history` sweep and missed this call site diff --git a/changelog.d/fixes/dbstat-optional-vtab.md b/changelog.d/fixes/dbstat-optional-vtab.md deleted file mode 100644 index 5d56e93d1a..0000000000 --- a/changelog.d/fixes/dbstat-optional-vtab.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(db):** database settings API no longer returns HTTP 500 on SQLite builds compiled without the optional `dbstat` virtual table (sql.js/WASM); per-table sizes degrade to 0 instead of failing the whole stats call diff --git a/changelog.d/fixes/discovery-metadata-effort-tiers.md b/changelog.d/fixes/discovery-metadata-effort-tiers.md deleted file mode 100644 index 3744a2063f..0000000000 --- a/changelog.d/fixes/discovery-metadata-effort-tiers.md +++ /dev/null @@ -1 +0,0 @@ -- fix(discovery): parse upstream reasoning tiers nested under metadata.reasoning.supported_efforts (neuralwatt /v1/models shape) so synced openai-compatible models advertise effort aliases diff --git a/changelog.d/fixes/docker-healthcheck-use-healthz.md b/changelog.d/fixes/docker-healthcheck-use-healthz.md deleted file mode 100644 index a139e2dd4c..0000000000 --- a/changelog.d/fixes/docker-healthcheck-use-healthz.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(ops):** Docker HEALTHCHECK probes lightweight `/healthz` instead of `/api/monitoring/health` so a busy event loop does not mark the container Unhealthy (`scripts/dev/healthcheck.mjs`) diff --git a/changelog.d/fixes/embed-gemini-missing-creds-hint.md b/changelog.d/fixes/embed-gemini-missing-creds-hint.md deleted file mode 100644 index 41b61713f7..0000000000 --- a/changelog.d/fixes/embed-gemini-missing-creds-hint.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(api):** `/v1/embeddings` 400s for native `gemini-embedding-2` now name the working OpenRouter ids (`openrouter/google/gemini-embedding-2` and the preview alias) instead of only `No credentials for embedding provider: gemini` — thanks @RaviTharuma diff --git a/changelog.d/fixes/forward-codex-quota-headers.md b/changelog.d/fixes/forward-codex-quota-headers.md deleted file mode 100644 index 86aa1e7df4..0000000000 --- a/changelog.d/fixes/forward-codex-quota-headers.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(sse):** keep Codex/Anthropic quota headers under the upstream forwarding budget; drop `x-codex-turn-state` and raise the 768-byte cap (`open-sse/handlers/chatCore/responseHeaders.ts`) diff --git a/changelog.d/fixes/minimax-music-generation-dispatch.md b/changelog.d/fixes/minimax-music-generation-dispatch.md deleted file mode 100644 index e0cf2114ca..0000000000 --- a/changelog.d/fixes/minimax-music-generation-dispatch.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(sse):** MiniMax music models now generate audio instead of failing with `Unsupported music format: minimax-music` — the provider entry was registered in the music registry (and advertised by `/v1/models`), but `handleMusicGeneration` had no branch for its format, so every `minimax/*` music request fell through the dispatch chain to a 400. Adds the missing dispatch: a single synchronous POST with the `base_resp` envelope check (a non-zero `status_code` arrives on HTTP 200 too), `data.status` handling (an unfinished generation is reported instead of polled — the operation has no task id and no query endpoint), `url` and `hex` output formats (hex normalized to base64), `mp3`/`wav`/`pcm` containers via `audio_setting`, and the regional endpoint through the per-connection base-URL override, which is also the only host that accepts `aigc_watermark`. The registry entry gains the generation and cover model ids it was missing and drops a query URL that does not exist for this operation. Regression guard: `tests/unit/minimax-music-generation.test.ts` (9 tests). diff --git a/changelog.d/fixes/models-dev-sync-env-killswitch.md b/changelog.d/fixes/models-dev-sync-env-killswitch.md deleted file mode 100644 index 0724f52356..0000000000 --- a/changelog.d/fixes/models-dev-sync-env-killswitch.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(models):** honor `MODELS_DEV_SYNC_ENABLED=0` as a hard kill switch over the dashboard setting so a wedged `/healthz` / UI can be recovered without HTTP (`src/lib/modelsDevSync.ts`) diff --git a/changelog.d/fixes/opencode-force-cli-ua.md b/changelog.d/fixes/opencode-force-cli-ua.md deleted file mode 100644 index f8a194a90b..0000000000 --- a/changelog.d/fixes/opencode-force-cli-ua.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** when `OPENCODE_SYNTHESIZE_CLI_HEADERS=true`, a non-CLI client User-Agent (e.g. `curl/8.5.0`, SDKs) on opencode-go/opencode-zen/opencode-free requests is now REPLACED with the synthesized `opencode-cli/1.0.0` instead of being honored — opencode.ai's free tier (`/zen/v1`) returns `FreeUsageLimitError` 429 for generic client UAs egressing from datacenter IPs, which made the #5997 CLI-identity synthesis ineffective for non-CLI clients. Client UAs already matching `opencode-cli/…` are preserved (the real CLI's versioned identity stays intact); all other client-supplied `x-opencode-*` headers keep client-wins. Regression guard: `tests/unit/opencode-cli-headers-synthesis-5997.test.ts` (7, incl. non-CLI UA replaced + CLI UA preserved). (#5997 follow-up) diff --git a/changelog.d/fixes/opencode-merge-provider-guard.md b/changelog.d/fixes/opencode-merge-provider-guard.md deleted file mode 100644 index aa1f02e63b..0000000000 --- a/changelog.d/fixes/opencode-merge-provider-guard.md +++ /dev/null @@ -1 +0,0 @@ -- **OpenCode config merge:** stop `mergeOpenCodeConfig` splaying a malformed `provider` block into index keys. The root was already guarded against a non-object; the `provider` branch it spreads one level down was not, so an existing `"provider": ["a", "b"]` merged to `{"0": "a", "1": "b", …}`. Its sibling `mergeOpenCodeConfigText` already refuses the same input. diff --git a/changelog.d/fixes/openrouter-synced-model-context-window-and-default-effort.md b/changelog.d/fixes/openrouter-synced-model-context-window-and-default-effort.md deleted file mode 100644 index b26e1c2086..0000000000 --- a/changelog.d/fixes/openrouter-synced-model-context-window-and-default-effort.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(models):** a model synced from a provider's own `/models` discovery is now enforced at its real context window immediately, instead of waiting up to 24h for the Feature 5004 reconciler's next tick. The request-time token-limit chain resolves the window from `auto:discovery` overrides, which previously were only written at startup and on a 24h interval — so any model synced mid-cycle (models.dev not indexing it yet, no static registry entry) fell through to the provider's static `defaultContextLength` (128K for OpenRouter) while `/v1/models` simultaneously advertised the real window from the same discovery data. Measured: `openrouter/stealth/ox-alpha` advertised `context_length: 1048576` but rejected requests over 128K with `context_length_exceeded` for a full day after its sync. The reconcile now also runs opportunistically (debounced, fire-and-forget) right after a synced catalog write changes. Companion fix: discovery now captures the vendor-declared `reasoning.default_effort` (e.g. OpenRouter `stealth/ox-alpha` declares `max`, normalized to `xhigh`) as `defaultThinkingEffort`, and the OpenAI dispatch path injects it when a request carries no reasoning field of any shape — the lowest-priority default behind a `-{effort}` suffix alias and a static `ModelSpec.defaultReasoningEffort` — so a reasoning model that returns an empty response without an explicit effort gets the vendor default instead of `upstream_empty_response`. diff --git a/changelog.d/fixes/pending-cc-cache-control-ttl-default.md b/changelog.d/fixes/pending-cc-cache-control-ttl-default.md deleted file mode 100644 index 0d89bee475..0000000000 --- a/changelog.d/fixes/pending-cc-cache-control-ttl-default.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(providers):** Claude Code / CC-protocol-compatible clients sending `cache_control` with no `ttl` on the native Claude OAuth path (`claude`/`cc`) now default to the 1h extended cache TTL instead of silently falling back to Anthropic's 5-minute default, even though the 1h beta is always negotiated on this path — thanks @jeff-alves diff --git a/changelog.d/fixes/pending-opencode-empty-rejection-rotation.md b/changelog.d/fixes/pending-opencode-empty-rejection-rotation.md deleted file mode 100644 index 82a88c5905..0000000000 --- a/changelog.d/fixes/pending-opencode-empty-rejection-rotation.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(executors):** OpencodeExecutor rotates (or retries once on a single-account direct path) on upstream 400 empty-body rejections — malformed completion envelopes with no error field were propagated as success and killed client sessions. Bounded +1 attempt per request; body reads are conditioned on status 400 so successful/streaming responses are never buffered. 400s carrying an error field keep propagating immediately. diff --git a/changelog.d/fixes/pending-opencode-jsonc-config.md b/changelog.d/fixes/pending-opencode-jsonc-config.md deleted file mode 100644 index 0951ae78f2..0000000000 --- a/changelog.d/fixes/pending-opencode-jsonc-config.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(cli):** recognize native `opencode.jsonc` files in OpenCode detection, generated-provider setup, and dashboard save/apply flows; preserve unrelated JSONC comments and provider settings, write updates back to the selected file, and refuse to overwrite invalid config ([#10227](https://github.com/diegosouzapw/OmniRoute/issues/10227)) — thanks @tito13kfm diff --git a/changelog.d/fixes/release-v3850-basereds-tests-i18n.md b/changelog.d/fixes/release-v3850-basereds-tests-i18n.md deleted file mode 100644 index 3a6dee61b9..0000000000 --- a/changelog.d/fixes/release-v3850-basereds-tests-i18n.md +++ /dev/null @@ -1 +0,0 @@ -- fix(i18n): complete Vietnamese translations for recently added UI strings (#9985) diff --git a/changelog.d/fixes/release-v3850-basereds.md b/changelog.d/fixes/release-v3850-basereds.md deleted file mode 100644 index 30444ba706..0000000000 --- a/changelog.d/fixes/release-v3850-basereds.md +++ /dev/null @@ -1,3 +0,0 @@ -- fix(api): repair broken `@/lib/db/connections` import in the usage utilization route that failed the production build (#10939 follow-up) -- chore(docs): regenerate PROVIDER_REFERENCE and refresh README diagram SVGs to the real provider count (347) -- chore(lint): prune ESLint suppressions orphaned on the release branch diff --git a/changelog.d/fixes/release-v3850-turbopack-build-red.md b/changelog.d/fixes/release-v3850-turbopack-build-red.md deleted file mode 100644 index 44f69203e3..0000000000 --- a/changelog.d/fixes/release-v3850-turbopack-build-red.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(build):** repair the broken Turbopack production build, the red lint gate and a runtime crash on `release/v3.8.50`. Six independent module-level defects, each from a different PR, had accumulated because the `Build` CI job is advisory rather than blocking: a lost closing brace in `modelSelectModalHelpers.ts` that swallowed `PROVIDER_TEST_CHUNK_SIZE` into a function body (#9011); `handleFalVideoGeneration` imported twice in `videoGeneration.ts` after the provider-neutral Fal module superseded the standalone handler (#9982 over #9969); `catalog.ts` still re-exporting and calling the injectable stale-while-revalidate policy that #9199 deliberately replaced with a fixed 30 s bound when it landed on top of #8728 — the consumer and the #8728 test suite were never realigned; two dangling statements left in `catalogCache.ts::scheduleBackgroundRefresh` referencing undeclared `inFlight`/`promise`, which made **every** stale-while-revalidate read throw a `ReferenceError` at runtime (a defect the build never caught, surfaced here by the realigned test); a generated wasm-bindgen sidecar URL in `tinycmsSigner.ts` that Turbopack resolves at build time even though the WASM module ships inlined as base64 (#8736/#10087); `conolDiscovery.ts` importing `getProviderOutboundGuard` from `outboundUrlGuard` instead of the sibling `outboundUrlGuardPolicy` module that actually exports it (#8974) — fixed on the consumer side, since re-exporting it would put a `@/`-aliased import into the module the packaged CLI loads without a tsconfig (#7682); and an unbalanced brace in `tests/unit/db-adapters/driverFactory.test.ts` where a new case was inserted between the preceding test's `finally` block and its `});`, so the whole file stopped parsing and the SQLite driver-cascade coverage silently stopped running since 2026-08-11 (#9173). diff --git a/changelog.d/fixes/secret-leak-error-surface-hardening.md b/changelog.d/fixes/secret-leak-error-surface-hardening.md deleted file mode 100644 index ed2e4474a0..0000000000 --- a/changelog.d/fixes/secret-leak-error-surface-hardening.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(security):** harden three secret-leak paths surfaced by an audit of the error/log surface. (1) `upstreamErrorPassthrough` relays an upstream provider's 4xx body verbatim to Claude-Code-format clients (the capability-recovery contract needs the exact wording); it now refuses passthrough when the body actually carries a credential pattern (`Bearer`/`Basic` token, `sk-…`, or an `api_key`/`token`/`authorization`/`cookie`/`secret` assignment) so a provider that echoes the offending request can't relay a key to the client, falling back to the sanitized error path. The credential regex is bounded (ReDoS-safe, verified linear at 60k chars). (2) The OCR and moderations handlers no longer forward an upstream error body byte-for-byte; they run it through the (now exported) structure-preserving `redactSensitiveErrorText` first. (3) `protectPayloadForLog`'s sensitive-key set gains `cookie`/`storageState`/`runtimeKey`/`capability` so web-impersonation credentials (Meta AI `ecto_1_sess`, chatgpt-web `storageState`) that land in a request/response body field are redacted before the call-log artifact is written to disk. No behavior change for secret-free error bodies; the Claude Code verbatim-wording contract is preserved. diff --git a/changelog.d/fixes/sqljs-atomic-persist.md b/changelog.d/fixes/sqljs-atomic-persist.md deleted file mode 100644 index db50495f4d..0000000000 --- a/changelog.d/fixes/sqljs-atomic-persist.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(db):** the sql.js fallback now publishes the database atomically — temp file in the same directory, `fsync`, then `rename()` — instead of rewriting it in place with `writeFileSync`. sql.js has no incremental write path, so every save rewrote the whole image through an `O_TRUNC` open: for the duration of the write the on-disk database was 0 bytes and then partial, a window that scales with database size and recurs on every save. Unlike better-sqlite3 / node:sqlite, that window is not covered by SQLite's locking protocol, so it was visible to every OTHER process reading the same file (a backup job, a metrics exporter, an operator running `sqlite3`), which got `SQLITE_CORRUPT` — "database disk image is malformed" — while `PRAGMA integrity_check` passed moments later. It also closes a total-loss window: a crash mid-write used to leave the real database truncated, and now only leaves a stale temp file diff --git a/changelog.d/maintenance/10297-k8s-probe-recommendations.md b/changelog.d/maintenance/10297-k8s-probe-recommendations.md deleted file mode 100644 index adc9d4491e..0000000000 --- a/changelog.d/maintenance/10297-k8s-probe-recommendations.md +++ /dev/null @@ -1 +0,0 @@ -- **docs(ops):** document Kubernetes probe recommendations — TCP (or soft HTTP) liveness, HTTP `/healthz` readiness, avoid `/api/monitoring/health` as kubelet liveness ([#10297](https://github.com/diegosouzapw/OmniRoute/pull/10297)) — thanks @RaviTharuma diff --git a/changelog.d/maintenance/10317-latest-tracks-highest-stable.md b/changelog.d/maintenance/10317-latest-tracks-highest-stable.md deleted file mode 100644 index 9fdb4d2c81..0000000000 --- a/changelog.d/maintenance/10317-latest-tracks-highest-stable.md +++ /dev/null @@ -1 +0,0 @@ -- **docs(docker):** spell out that `:latest` tracks the highest **published** stable SemVer (not git `main`), and that GitOps should pin `X.Y.Z` ([#10317](https://github.com/diegosouzapw/OmniRoute/issues/10317)) diff --git a/changelog.d/maintenance/10349-optional-work-event-loop.md b/changelog.d/maintenance/10349-optional-work-event-loop.md deleted file mode 100644 index 0cd695e4a7..0000000000 --- a/changelog.d/maintenance/10349-optional-work-event-loop.md +++ /dev/null @@ -1 +0,0 @@ -- **docs(backend):** document that memory extraction, skills injection, and token refresh share the request event loop, plus dashboard kill switches ([#10349](https://github.com/diegosouzapw/OmniRoute/issues/10349)) diff --git a/changelog.d/maintenance/10350-sqlite-single-replica-ha.md b/changelog.d/maintenance/10350-sqlite-single-replica-ha.md deleted file mode 100644 index 9b158d5787..0000000000 --- a/changelog.d/maintenance/10350-sqlite-single-replica-ha.md +++ /dev/null @@ -1 +0,0 @@ -- **docs(docker):** document default SQLite as single-replica / HA-unsupported, including Recreate and HEALTHCHECK session blast radius ([#10350](https://github.com/diegosouzapw/OmniRoute/issues/10350)) diff --git a/changelog.d/maintenance/10351-pre-write-backup-throttle.md b/changelog.d/maintenance/10351-pre-write-backup-throttle.md deleted file mode 100644 index f6141d30ea..0000000000 --- a/changelog.d/maintenance/10351-pre-write-backup-throttle.md +++ /dev/null @@ -1 +0,0 @@ -- **docs(backend):** document that pre-write SQLite backups (including models.dev pricing) are throttled to once per 60 minutes and can be disabled with `DISABLE_SQLITE_AUTO_BACKUP` ([#10351](https://github.com/diegosouzapw/OmniRoute/issues/10351)) diff --git a/changelog.d/maintenance/10704-basereds-sse-comments-vi-parity.md b/changelog.d/maintenance/10704-basereds-sse-comments-vi-parity.md deleted file mode 100644 index e2874a2be4..0000000000 --- a/changelog.d/maintenance/10704-basereds-sse-comments-vi-parity.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(tests):** drain three base-reds on the release branch — the Vietnamese locale regained parity with English (6 keys added), the chatCore SSE test now asserts the comment-free default that #10539 introduced instead of the trailer it replaced, and the Antigravity cloudcode test asserts the missing-messages guard it is named for instead of a `/ok/` regex that only ever matched the "ok" inside `: x-omniroute-tokens-in` ([#10704](https://github.com/diegosouzapw/OmniRoute/pull/10704)) diff --git a/changelog.d/maintenance/10775-remove-dead-enforce-secrets.md b/changelog.d/maintenance/10775-remove-dead-enforce-secrets.md deleted file mode 100644 index 4f44473360..0000000000 --- a/changelog.d/maintenance/10775-remove-dead-enforce-secrets.md +++ /dev/null @@ -1 +0,0 @@ -- chore(security): remove the unused `enforceSecrets()` duplicate of the boot secret check and pin the live `enforceWebRuntimeEnv()` wiring with a regression test (#10775) diff --git a/changelog.d/maintenance/10778-grokbuild-suppression-fix.md b/changelog.d/maintenance/10778-grokbuild-suppression-fix.md deleted file mode 100644 index 15c5df7ef7..0000000000 --- a/changelog.d/maintenance/10778-grokbuild-suppression-fix.md +++ /dev/null @@ -1 +0,0 @@ -- fix(quality): register GrokBuildToolCard.tsx react-hooks/set-state-in-effect suppression (dropped in #10778's uncommitted fix) diff --git a/changelog.d/maintenance/10779-combo-invocation-docs.md b/changelog.d/maintenance/10779-combo-invocation-docs.md deleted file mode 100644 index 00138a3a65..0000000000 --- a/changelog.d/maintenance/10779-combo-invocation-docs.md +++ /dev/null @@ -1 +0,0 @@ -- **docs:** Custom combos are only invoked by their exact name in the `model` field — `auto` remains a separate zero-config router, and `openrouter/auto` is a paid OpenRouter product, not an alias ([#10779](https://github.com/diegosouzapw/OmniRoute/pull/10779)) — thanks @maxmad64bis diff --git a/changelog.d/maintenance/10780-server-init-dead-code.md b/changelog.d/maintenance/10780-server-init-dead-code.md deleted file mode 100644 index ffe204a233..0000000000 --- a/changelog.d/maintenance/10780-server-init-dead-code.md +++ /dev/null @@ -1 +0,0 @@ -- chore(startup): remove `src/server-init.ts` (183 lines, never imported — the boot path is `src/instrumentation-node.ts`) and correct four `"called from server-init.ts"` comments left pointing at the dead entry point (#10780) diff --git a/changelog.d/maintenance/10859-filesize-baseline-fix.md b/changelog.d/maintenance/10859-filesize-baseline-fix.md deleted file mode 100644 index aed0a3fab5..0000000000 --- a/changelog.d/maintenance/10859-filesize-baseline-fix.md +++ /dev/null @@ -1 +0,0 @@ -- fix(quality): rebaseline file-size for #10859's own modelCapabilities.ts/commandCode.ts growth (missed at merge time) diff --git a/changelog.d/maintenance/10875-combos-id-verb-coverage.md b/changelog.d/maintenance/10875-combos-id-verb-coverage.md deleted file mode 100644 index 5610a7ea41..0000000000 --- a/changelog.d/maintenance/10875-combos-id-verb-coverage.md +++ /dev/null @@ -1 +0,0 @@ -- **docs(openapi):** document the `GET` and `PUT` operations on `/api/combos/{id}`, and add an operation-level coverage floor so a missing verb can no longer hide behind a path that already counts as covered ([#10875](https://github.com/diegosouzapw/OmniRoute/pull/10875)) diff --git a/changelog.d/maintenance/10889-feature-flag-count-fix.md b/changelog.d/maintenance/10889-feature-flag-count-fix.md deleted file mode 100644 index fd93221987..0000000000 --- a/changelog.d/maintenance/10889-feature-flag-count-fix.md +++ /dev/null @@ -1 +0,0 @@ -- fix(quality): bump EXPECTED_FEATURE_FLAG_COUNT to 52 for #10889's own new flag (missed at merge time) diff --git a/changelog.d/maintenance/10906-critical-db-state-assertions.md b/changelog.d/maintenance/10906-critical-db-state-assertions.md deleted file mode 100644 index c63f5f0039..0000000000 --- a/changelog.d/maintenance/10906-critical-db-state-assertions.md +++ /dev/null @@ -1 +0,0 @@ -- **test(db):** replace three empty `test.skip` placeholders in the critical DB-state suite with real assertions — `resetDbInstance` must swap the singleton while the on-disk row survives, the on-disk DB must open in WAL journal mode, and `db_meta` must hold the seeded `schema_version` — so a regression in any of those invariants can no longer pass as silently green ([#10906](https://github.com/diegosouzapw/OmniRoute/pull/10906)) diff --git a/changelog.d/maintenance/10982-runtime-ram-coding-agents.md b/changelog.d/maintenance/10982-runtime-ram-coding-agents.md deleted file mode 100644 index 3c0985c68f..0000000000 --- a/changelog.d/maintenance/10982-runtime-ram-coding-agents.md +++ /dev/null @@ -1 +0,0 @@ -- **docs(docker):** document runtime RAM for coding-agent `/v1/responses` (image default 1 GiB heap is dashboard-only; 8–12 GiB heap for agents) ([#10982](https://github.com/diegosouzapw/OmniRoute/issues/10982)) diff --git a/changelog.d/maintenance/11018-database-cache-docs.md b/changelog.d/maintenance/11018-database-cache-docs.md deleted file mode 100644 index a7230d02fd..0000000000 --- a/changelog.d/maintenance/11018-database-cache-docs.md +++ /dev/null @@ -1 +0,0 @@ -- **docs(database):** align the SQLite cache guide with the 65,536 KiB runtime default, supported 1–1,000,000 KiB range, and live Settings application behavior ([#11018](https://github.com/diegosouzapw/OmniRoute/issues/11018)) diff --git a/changelog.d/maintenance/11024-n-instance-scale-out.md b/changelog.d/maintenance/11024-n-instance-scale-out.md deleted file mode 100644 index 82adafe480..0000000000 --- a/changelog.d/maintenance/11024-n-instance-scale-out.md +++ /dev/null @@ -1 +0,0 @@ -- **docs(docker):** document N independent `DATA_DIR`s as the supported large `/v1/responses` scale-out (one V8 heap ≠ host RAM; do not `replicas>1` on one SQLite file) ([#11024](https://github.com/diegosouzapw/OmniRoute/issues/11024)) — thanks @RaviTharuma diff --git a/changelog.d/maintenance/11038-filesize-baseline-fix.md b/changelog.d/maintenance/11038-filesize-baseline-fix.md deleted file mode 100644 index aebc2b9e23..0000000000 --- a/changelog.d/maintenance/11038-filesize-baseline-fix.md +++ /dev/null @@ -1 +0,0 @@ -- fix(quality): rebaseline file-size for modelCapabilities.ts (1016->1072) drift from merged tip fixes (#11034 et al) diff --git a/changelog.d/maintenance/11053-stryker-oauth-autoimport-registration.md b/changelog.d/maintenance/11053-stryker-oauth-autoimport-registration.md deleted file mode 100644 index b2e2141900..0000000000 --- a/changelog.d/maintenance/11053-stryker-oauth-autoimport-registration.md +++ /dev/null @@ -1 +0,0 @@ -- fix(quality): register `tests/unit/authz/oauth-autoimport-local-only.test.ts` in stryker `tap.testFiles` (residual of #11053) diff --git a/changelog.d/maintenance/11160-drain-v3850-basereds-docs-counts-orphan-test.md b/changelog.d/maintenance/11160-drain-v3850-basereds-docs-counts-orphan-test.md deleted file mode 100644 index e695a2b8fc..0000000000 --- a/changelog.d/maintenance/11160-drain-v3850-basereds-docs-counts-orphan-test.md +++ /dev/null @@ -1 +0,0 @@ -- chore(quality): drain two `release/v3.8.50` base-reds — refresh the drifted doc counts (159 migrations, 56 free-forever providers, 40 free-tier pools, incl. the 42 `llm.txt` locale mirrors) and move `uncloseai-noauth.test.ts` to a collected path so the UncloseAI no-auth regression guard actually runs (#11160) diff --git a/changelog.d/maintenance/11247-ratchet-no-unused-vars.md b/changelog.d/maintenance/11247-ratchet-no-unused-vars.md deleted file mode 100644 index f86559b7b6..0000000000 --- a/changelog.d/maintenance/11247-ratchet-no-unused-vars.md +++ /dev/null @@ -1 +0,0 @@ -- **chore(lint):** ratchet `@typescript-eslint/no-unused-vars` scoped to `src/` + `open-sse/` + `tests/` (`args: "all"`, `_`-prefix escape hatch) and freeze the 1393 pre-existing violations via bulk suppressions — same pattern as the #7879 `toNumber` ratchet. New unused bindings now fail lint. ([#11247](https://github.com/diegosouzapw/OmniRoute/pull/11247)) diff --git a/changelog.d/maintenance/7786-management-auth-guide.md b/changelog.d/maintenance/7786-management-auth-guide.md deleted file mode 100644 index 54f84a79b9..0000000000 --- a/changelog.d/maintenance/7786-management-auth-guide.md +++ /dev/null @@ -1 +0,0 @@ -- **docs(auth):** distinguish dashboard sessions, `oma_live_…` Access Tokens, manage-scoped API keys, and inference keys ([#7786](https://github.com/diegosouzapw/OmniRoute/issues/7786)) diff --git a/changelog.d/maintenance/embeddings-client-runbook.md b/changelog.d/maintenance/embeddings-client-runbook.md deleted file mode 100644 index da47b8d261..0000000000 --- a/changelog.d/maintenance/embeddings-client-runbook.md +++ /dev/null @@ -1 +0,0 @@ -- **docs:** add an embeddings client runbook with live-verified working/broken model ids and Hindsight 0.9.1 / Memorix 1.6.0 notes — thanks @RaviTharuma diff --git a/changelog.d/maintenance/env-doc-sync-adhoc-bot.md b/changelog.d/maintenance/env-doc-sync-adhoc-bot.md deleted file mode 100644 index dbd759be29..0000000000 --- a/changelog.d/maintenance/env-doc-sync-adhoc-bot.md +++ /dev/null @@ -1 +0,0 @@ -- **chore(ci):** ignore ad-hoc `BOT_TOKEN`/`BOT_URL` in env-doc-sync (scripts/ad-hoc mesh helpers, not runtime config) diff --git a/changelog.d/maintenance/regen-translate-path-golden-freebuff.md b/changelog.d/maintenance/regen-translate-path-golden-freebuff.md deleted file mode 100644 index 7822df2283..0000000000 --- a/changelog.d/maintenance/regen-translate-path-golden-freebuff.md +++ /dev/null @@ -1 +0,0 @@ -- chore(test): regenerate the provider/translate-path golden snapshot to reflect freebuff (#10531), fixing a base-red left by that merge (freebuff/freeinference key ordering only, no value changes). diff --git a/changelog.d/maintenance/release-v3850-base-reds-20260817.md b/changelog.d/maintenance/release-v3850-base-reds-20260817.md deleted file mode 100644 index d435b50096..0000000000 --- a/changelog.d/maintenance/release-v3850-base-reds-20260817.md +++ /dev/null @@ -1 +0,0 @@ -- **chore(release):** resync the v3.8.50 provider and CLI catalogs, register the existing ChatCore mutation-coverage test, and document the local ZCode handshake identifier so the release quality gates reflect the current tree without changing ratchet baselines. diff --git a/changelog.d/maintenance/release-v3850-basereds-error-helper-20260819.md b/changelog.d/maintenance/release-v3850-basereds-error-helper-20260819.md deleted file mode 100644 index e89d53ab49..0000000000 --- a/changelog.d/maintenance/release-v3850-basereds-error-helper-20260819.md +++ /dev/null @@ -1,3 +0,0 @@ -- **fix(ci):** route `open-sse/handlers/imageGeneration/providers/geminiWeb.ts`'s b64_json - download-failure message through `sanitizeErrorMessage()` instead of embedding a raw - `err.message`, clearing the `check:error-helper` base-red on `release/v3.8.50` (#9985). diff --git a/changelog.d/maintenance/release-v3850-basereds-eslint-deadcode-vitest-20260819.md b/changelog.d/maintenance/release-v3850-basereds-eslint-deadcode-vitest-20260819.md deleted file mode 100644 index 905d338575..0000000000 --- a/changelog.d/maintenance/release-v3850-basereds-eslint-deadcode-vitest-20260819.md +++ /dev/null @@ -1,20 +0,0 @@ -- **fix(ci):** drain three more base-reds on `release/v3.8.50` (#9985). ESLint was reporting - 219 errors locally (vs. 25 in the last CI run) — all from `react-hooks/set-state-in-effect`, - `react-hooks/preserve-manual-memoization`, `react-hooks/immutability`, - `react-hooks/static-components`, `react-hooks/refs` and `react-hooks/purity`, six React - Compiler lint rules that `eslint-plugin-react-hooks` v7 turns on by default and that were - never frozen in `config/quality/eslint-suppressions.json` after the dependency bump. Froze - the pre-existing violations for those six rules via ESLint's native - `--suppress-rule`/`--suppressions-location` mechanism (the same pattern already used for - `@next/next/no-location-assign-relative-destination`) — no application code changed, no rule - disabled, only genuinely-new violations stay blocking. `check:dead-code` was at 418 against a - 415 baseline: removed the unused `src/lib/quota/providerCapabilities.ts` file and the unused - `ProviderQuotaMonitor` interface in `providerQuotaTelemetry.ts` (both dead since PR #10148, - 2026-08-18, confirmed via `grep`/knip cross-reference), landing at 416; the residual +1 could - not be attributed to a single recent commit after checking every dead-list entry touched - since the 2026-08-14 baseline measurement, so it is rebaselined with the investigation - recorded in `quality-baseline.json`. `tests/unit/autoCombo/tieredRotation.test.ts`'s - "rotates across all 43 Cerebras connection IDs" case was hitting vitest's 5000ms default - timeout on a 200-iteration synchronous `selectProvider()` loop under shared-devbox - contention (load average 40-60+ observed) — widened its explicit timeout to 20000ms; the - assertion itself is unchanged. diff --git a/changelog.d/maintenance/release-v3850-basereds-glm-family-20260819.md b/changelog.d/maintenance/release-v3850-basereds-glm-family-20260819.md deleted file mode 100644 index 04985623c0..0000000000 --- a/changelog.d/maintenance/release-v3850-basereds-glm-family-20260819.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(tests):** drain two base-reds on the release branch — `auto/glm` now expects the Cloudflare AI Playground backend (its registry advertises `zai-org/glm-5.2` and `zai-org/glm-4.7-flash`, so it belongs in the family pool by the same rule already documented for `auggie`, `devin-cli-agentic` and `zcode`), and the ESLint gate is green again after the GitLab executor test dropped its five `as any` casts for a declared response shape and the CLI OAuth suppression count caught up with the two casts #10491 added. diff --git a/changelog.d/maintenance/release-v3850-basereds-stream-utils-20260820.md b/changelog.d/maintenance/release-v3850-basereds-stream-utils-20260820.md deleted file mode 100644 index 98b62cd448..0000000000 --- a/changelog.d/maintenance/release-v3850-basereds-stream-utils-20260820.md +++ /dev/null @@ -1 +0,0 @@ -- **fix(tests):** realign the two `stream-utils` passthrough cases that still asserted the pre-#10017 SSE framing — the event-boundary case declares the OpenAI Responses client format it actually exercises, and the metadata case now pins that surviving lines stay inside one event instead of expecting the `:`/`id:` control lines that #10473 stopped forwarding to every client format. diff --git a/changelog.d/maintenance/release-v3850-basereds-testdrift-20260819.md b/changelog.d/maintenance/release-v3850-basereds-testdrift-20260819.md deleted file mode 100644 index f4deae6aae..0000000000 --- a/changelog.d/maintenance/release-v3850-basereds-testdrift-20260819.md +++ /dev/null @@ -1,12 +0,0 @@ -- **fix(tests):** drain several base-reds on `release/v3.8.50` (#9985) that were all instances - of the same pattern — a legitimate product change landed without updating the test that - asserted the old behavior: `tests/unit/glm-provider-model-import-route.test.ts` (12 tests) - and `tests/unit/model-sync-route.test.ts` (2 tests) predate #10603's "upstream model sync is - opt-in and manual overrides are preserved" change; `tests/unit/antigravity-model-aliases.test.ts` - predated #10537 retiring the collapsed `gemini-3.7-flash` alias in favor of its three tiered - ids. Also fixes a real data drift in `open-sse/config/freeModelCatalog.data.ts` (the `qwen-web` - free-catalog entry still pointed at the retired `qwen3.8-max-preview` id instead of the - current `qwen3.8-max`), corrects the zh-TW `providers.autoFetchModelsTooltip` string to the - glossary-canonical 快取 instead of 緩存, and removes an unused default export from - `src/lib/oauth/providers/zed-hosted.ts` (the named export already covers every consumer) to - shave one symbol off the `check:dead-code` ratchet regression. diff --git a/changelog.d/maintenance/release-v3850-docs-env-basereds-20260817.md b/changelog.d/maintenance/release-v3850-docs-env-basereds-20260817.md deleted file mode 100644 index d2ec7afbd4..0000000000 --- a/changelog.d/maintenance/release-v3850-docs-env-basereds-20260817.md +++ /dev/null @@ -1 +0,0 @@ -- **chore(release):** synchronize migration-count documentation and document the opt-in `PROXY_LOG_INCLUDE_IPS` logging flag so the v3.8.50 quality gates match the release tree. diff --git a/changelog.d/maintenance/vi-harimport-parity.md b/changelog.d/maintenance/vi-harimport-parity.md deleted file mode 100644 index b08b8dc92f..0000000000 --- a/changelog.d/maintenance/vi-harimport-parity.md +++ /dev/null @@ -1 +0,0 @@ -- fix(i18n): translate the 14 `providers.harImport*` keys into Vietnamese (parity gap left by #11069) diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 020a0e7c0c..8ab2c63cee 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -935,7 +935,7 @@ }, "src/app/(dashboard)/dashboard/combos/page.tsx": { "@typescript-eslint/no-unused-vars": { - "count": 9 + "count": 6 } }, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": { diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index d8c277307a..9d800e877b 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -193,61 +193,43 @@ "_rebaseline_2026_08_24_video_bridge_fu01_fu03_fu04_result_cache_tests": "PRs #11362 (FU-01 cache hardening) + #11382 (FU-03 visual dedup policy identity) + #11383 (FU-04 focused analysis mode) own test growth: videoBridgeResultCache.test.ts <1000->1040, +40 (sum of three stacked PRs boarded together in the same merge-batch, each adding its own cache-identity assertions on the shared result-cache seam). Owner pre-authorized rebaseline for legitimate PR growth (2026-08-19 directive).", "_rebaseline_basered_codebuddy_cn": "Base-red fix (#4664 CodeBuddy CN): oauth-providers-config.test.ts 867->870 (+3) to align the EXPECTED provider list/config with the codebuddy-cn provider that #4664 added to the registry without updating this test (it asserts 'exactly once').", "_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", - "tests/integration/chat-pipeline.test.ts": 1604, - "tests/integration/chatcore-compression-integration.test.ts": 1114, + "tests/integration/chat-pipeline.test.ts": 2077, + "tests/integration/chatcore-compression-integration.test.ts": 1448, "tests/integration/skills-pipeline.test.ts": 1006, - "tests/unit/account-fallback-service.test.ts": 2008, - "tests/unit/adobe-firefly.test.ts": 1136, - "tests/unit/batch_api.test.ts": 1324, - "tests/unit/cc-compatible-provider.test.ts": 1225, - "tests/unit/chatcore-translation-paths.test.ts": 3446, - "tests/unit/combo-routing-engine.test.ts": 3625, - "tests/unit/db-migration-runner.test.ts": 1509, - "tests/unit/deepseek-web.test.ts": 1141, - "tests/unit/executor-antigravity.test.ts": 1103, - "tests/unit/executor-codex.test.ts": 1467, - "tests/unit/executor-default-base.test.ts": 1632, - "tests/unit/grok-web.test.ts": 2437, - "tests/unit/image-generation-handler.test.ts": 2083, - "tests/unit/model-sync-route.test.ts": 1033, - "tests/unit/models-catalog-route.test.ts": 1652, - "tests/unit/perplexity-web.test.ts": 1384, - "tests/unit/provider-models-route.test.ts": 1783, - "tests/unit/provider-validation-specialty.test.ts": 2912, - "tests/unit/providers-page-utils.test.ts": 1149, - "tests/unit/response-sanitizer.test.ts": 1089, - "tests/unit/route-edge-coverage.test.ts": 1241, - "tests/unit/search-handler-extended.test.ts": 1070, - "tests/unit/sse-auth.test.ts": 1698, - "tests/unit/stream-utils.test.ts": 2512, - "tests/unit/token-refresh-service.test.ts": 1407, - "tests/unit/translator-openai-responses-req.test.ts": 1470, - "tests/unit/translator-openai-to-gemini.test.ts": 1625, - "tests/unit/translator-openai-to-kiro.test.ts": 1275, - "tests/unit/translator-resp-gemini-to-openai.test.ts": 1234, - "tests/unit/usage-service-hardening.test.ts": 1485, - "tests/unit/vscode-token-routes.test.ts": 1259, + "tests/unit/account-fallback-service.test.ts": 2032, + "tests/unit/adobe-firefly.test.ts": 1477, + "tests/unit/batch_api.test.ts": 1721, + "tests/unit/cc-compatible-provider.test.ts": 1582, + "tests/unit/chatcore-translation-paths.test.ts": 3739, + "tests/unit/combo-routing-engine.test.ts": 4494, + "tests/unit/db-migration-runner.test.ts": 1949, + "tests/unit/deepseek-web.test.ts": 1420, + "tests/unit/executor-antigravity.test.ts": 1427, + "tests/unit/executor-codex.test.ts": 1741, + "tests/unit/executor-default-base.test.ts": 1975, + "tests/unit/grok-web.test.ts": 3168, + "tests/unit/image-generation-handler.test.ts": 2638, + "tests/unit/model-sync-route.test.ts": 1321, + "tests/unit/models-catalog-route.test.ts": 2127, + "tests/unit/perplexity-web.test.ts": 1762, + "tests/unit/provider-models-route.test.ts": 2323, + "tests/unit/provider-validation-specialty.test.ts": 3880, + "tests/unit/providers-page-utils.test.ts": 1438, + "tests/unit/response-sanitizer.test.ts": 1382, + "tests/unit/route-edge-coverage.test.ts": 1613, + "tests/unit/search-handler-extended.test.ts": 1392, + "tests/unit/sse-auth.test.ts": 2093, + "tests/unit/stream-utils.test.ts": 3178, + "tests/unit/token-refresh-service.test.ts": 1791, + "tests/unit/translator-openai-responses-req.test.ts": 1552, + "tests/unit/translator-openai-to-gemini.test.ts": 2109, + "tests/unit/translator-openai-to-kiro.test.ts": 1658, + "tests/unit/translator-resp-gemini-to-openai.test.ts": 1604, + "tests/unit/usage-service-hardening.test.ts": 1928, + "tests/unit/vscode-token-routes.test.ts": 1633, "tests/unit/guardrails/videoBridgeResultCache.test.ts": 1040, - "tests/unit/image-generation-handler.test.ts": 2083, - "tests/unit/model-sync-route.test.ts": 1033, - "tests/unit/models-catalog-route.test.ts": 1652, - "tests/unit/perplexity-web.test.ts": 1384, - "tests/unit/provider-models-route.test.ts": 1783, - "tests/unit/provider-validation-specialty.test.ts": 3070, - "tests/unit/providers-page-utils.test.ts": 1149, - "tests/unit/reasoning-cache.test.ts": 1291, - "tests/unit/response-sanitizer.test.ts": 1089, - "tests/unit/route-edge-coverage.test.ts": 1241, - "tests/unit/search-handler-extended.test.ts": 1070, - "tests/unit/sse-auth.test.ts": 1698, - "tests/unit/stream-utils.test.ts": 2512, - "tests/unit/token-refresh-service.test.ts": 1407, - "tests/unit/translator-openai-responses-req.test.ts": 1470, - "tests/unit/translator-openai-to-gemini.test.ts": 1625, - "tests/unit/translator-openai-to-kiro.test.ts": 1275, - "tests/unit/translator-resp-gemini-to-openai.test.ts": 1234, - "tests/unit/usage-service-hardening.test.ts": 1485, - "tests/unit/vscode-token-routes.test.ts": 1259 + "tests/unit/reasoning-cache.test.ts": 1346, + "tests/unit/chatgpt-web.test.ts": 4092 }, "_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.", "_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap.", @@ -489,19 +471,10 @@ "_rebaseline_2026_08_21_10859_vision_bridge_catalog": "#10859 own growth (Vision Bridge fixes #10808/#10809): src/lib/modelCapabilities.ts 1006->1016 (+10, cmd/gpt-5.3-codex* text-only capability resolution) and open-sse/executors/commandCode.ts 988->1023 (+35, Command Code wire-model normalization for bare ids + reasoning field fallback for opencode-routed gateways). Cohesive bug fixes at the existing capability-resolution / executor chokepoints; not extractable mid-fix. Covered by tests/unit/model-capabilities-command-code-codex-textonly-10703.test.ts, tests/unit/command-code-vision.test.ts, tests/unit/opencode-mimo-reasoning-details-nonstream.test.ts. Pushed directly to release (own-session miss: the original rebaseline was made in a throwaway validation worktree and never landed on the PR branch or the release before merge).", "_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.", "_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming).", - "_rebaseline_2026_08_21_11034_effort_variants": "DRIFT do tip (base-red #9985): modelCapabilities.ts 1016->1072 (+56) acumulado por PRs ja mergeadas no release/v3.8.50 — principalmente #11034 (resolve effort-variant capabilities a partir do modelo base), alem de #10963/#11040/#10987 growth dos catalogos. Tip puro ficou vermelho neste gate; rebaseline no tip por push direto (owner pre-autorizou crescimento legitimo). Nao tocou no arquivo da #11038.", "_rebaseline_2026_08_21_11069_m365_har_import": "#11069 own growth: AddApiKeyModal.tsx 1073->1080 (+7 = Import .har file button for the copilot-m365-web credential modal — M365 is the only provider whose credential (access_token+chathubPath) must be extracted from a DevTools HAR WebSocket URL, added as a new modal affordance). Cohesive UI at the existing modal chokepoint; not extractable. Covered by tests/unit/m365-har-import*.test.ts.", - "_rebaseline_2026_08_22_11020_sigterm_drain": "PR #11020 (RaviTharuma) own growth: chatBodyAdmission.ts 1005->1009 (+4, heavyweight admission leases now increment the SIGTERM drain counter and releaseChatAdmissionWhenDone holds it for the SSE lifetime — closes #11015; +4 are the lease/drain wiring lines at the existing admission chokepoint). Covered by tests/unit/chat-body-admission.test.ts heavyweight-lease cases. Owner pre-authorized baseline bumps 2026-08-22.", - "_rebaseline_2026_08_22_11084_ccr_caller_gate": "PR #11084 (HouMinXi) own growth: open-sse/services/compression/engines/ccr/index.ts 1000->1024 (first listing — the engine was unlisted and drifted just over the 1000 cap; +24 are the callerSupportsCcrRetrieve gate that skips replacement entirely for callers without the retrieve tool, closing the stranded-prompt incident measured in production). Covered by tests/unit/compression/ccr-non-mcp-full-prompt-loss-7746.test.ts. Owner pre-authorized baseline bumps 2026-08-22.", - "_rebaseline_2026_08_22_11113_purify_system_first": "PR #11113 (ggdayup) own growth: open-sse/services/contextManager.ts 1000->1001 (+1, purifyHistory merges the compression notice into the leading system message instead of splicing a second one mid-array — live-confirmed TokenRouter 400s; the +1 is the merge-into-leading branch, not extractable). Covered by tests/unit/context-manager-purify-system-first.test.ts. Owner pre-authorized baseline bumps 2026-08-22.", - "_rebaseline_2026_08_22_11156_enter_check_disabled": "PR #11156 (rqzbeh) own growth: AddApiKeyModal.tsx 1080->1082 (+2, Enter keydown handler now mirrors the isCheckDisabled condition — owner-requested post-merge polish from #11056; the rest of the diff is Prettier reflow). Covered by tests/unit/ui/add-api-key-modal-enter-key.test.tsx (jsdom render test, Enter dispatch assertions).", "_rebaseline_2026_08_23_11141_oauth_400_recovery": "PR #11141 (HouMinXi) own growth: test/route.ts 1025->1215 (+190, the reactive-400 recovery path — a fully rebuilt probe for refresh+retry on refreshable non-rotating connections, with inconclusive-status preservation and rotating-provider exclusion; all growth is the new probe builder + guards at the existing test-route dispatch, extraction would split the retry flow mid-logic). Covered by tests/unit/oauth-400-recovery.test.ts (8, bug-injection proof). Owner pre-authorized baseline bumps 2026-08-22.", - "_rebaseline_2026_08_23_11177_dns_retry_classification": "PR #11177 (rqzbeh) own growth: proxyFetch.ts 1239->1244 (+5, EAI_AGAIN/ENOTFOUND/ETIMEDOUT join the retryable dispatcher classification alongside ECONNREFUSED — bounded socket retries for transient DNS failures, part of the #10443 Hermes→Antigravity stream-drop fixes). Covered by tests/unit/proxy-fetch-dns-retry-10443.test.ts. Owner pre-authorized baseline bumps 2026-08-22.", - "_rebaseline_2026_08_23_11186_synced_inventory_routing": "PR #11186 (pacocartones) own growth: src/sse/services/auth.ts 3260->3337 (+77, loadAdvertisedModelsForSelfHostedConnections + the modelNotAdvertised candidate-filter predicate — pins chat routing to the connection whose synced inventory actually advertises the model, fixing spurious model-not-found on multi-host self-hosted setups; at the existing credential-selection chokepoint, not extractable without splitting the selection flow). Covered by tests/unit/chat-routing-synced-inventory-11089.test.ts. Owner pre-authorized baseline bumps 2026-08-22.", - "_rebaseline_2026_08_23_11207_aws_polly_fields": "PR #11207 (rafacpti23, draft) own growth: AddApiKeyModal.tsx 1082->1173 (+91, AWS SigV4 credential fields for aws-polly — Access Key ID / Region / optional Session Token blocks with providerText i18n labels, at the existing per-provider form-section chokepoint; the file is the known god-modal with repeated dated rebaselines). Covered by tests/unit/dashboard/aws-polly-connection-modal-fields.test.ts. Owner pre-authorized baseline bumps 2026-08-22.", "_rebaseline_2026_08_23_tip_drift_post_batch0823": "Tip drift after the 2026-08-23 merge wave: chatBodyAdmission.ts 1009->1118 (+109, gate count incl. +1) and auth.ts 3337->3344 (+7), both grown by merges already on origin/release/v3.8.50 (verified identical on the pristine tip) — not by the codex-appserver-hardening PR that carries this bump. Owner pre-authorized baseline bumps 2026-08-22.", "_rebaseline_2026_08_24_11355_cooldown_recovery_guards": "PR #11355 own growth: test/route.ts 1215->1237, +22 (startup crash-recovery guard: clearStaleCrashCooldowns() now parses the persisted rate_limited_until deadline and skips clearing rows still genuinely in the future, instead of clearing every non-terminal cooldown unconditionally). Cohesive fix at the existing test-route dispatch chokepoint alongside the #11141 probe builder. Covered by tests/unit/startup-stale-cooldown-recovery.test.ts + tests/unit/repro-zai-cooldown-cleared-by-connection-test.test.ts.", - "_rebaseline_2026_08_24_lasterror_provider_error_detail": "PR (ntdat812) own growth: src/sse/services/auth.ts 3344->3346 (+2). One line is the import of describeUpstreamFailure from @/shared/utils/upstreamError, which replaces the string-only collapse `typeof errorText === \"string\" ? errorText.slice(0, 100) : \"Provider error\"` at the single markAccountUnavailable chokepoint (net 0 lines there) — the logic itself lives in upstreamError.ts, next to the extractErrorMessage it reuses, so nothing else moved into this file. The second line is the repo's own lint-staged prettier pass splitting a pre-existing two-statements-on-one-line at getProviderCredentials (`invalidateManagedLease(...); log.warn(...)`); it re-applies on any commit that touches this file, so it is not separable from the change. Covered by tests/unit/provider-error-detail-lastError.test.ts.", "_rebaseline_2026_08_24_video_bridge_fu02_fu07_sampler": "PRs #11344 (FU-02 one-frame scene-aware determinism) + #11381 (FU-07 opt-in segment_aware structural sampling) own growth: videoBridgeRuntime.ts <1000->1009, +9 (sum of both boarded together in the same merge-batch). #11344 adds the deterministic one-frame midpoint fallback + policyEffective=uniform report at the existing scene_aware seam; #11381 adds the bounded local-only FFmpeg structural pre-analysis pass (scene/freeze/blur/exposure/SI-TI) and its budget-reallocation logic. Covered by tests/unit/guardrails/videoBridgeSampler.test.ts, tests/unit/guardrails/videoBridgeFu07StructuralSampling.test.ts, tests/integration/video-bridge-sampler-ffmpeg.test.ts. Owner pre-authorized rebaseline for legitimate PR growth (2026-08-19 directive).", "open-sse/services/autoCombo/virtualFactory.ts": 1138, "_rebaseline_2026_08_28_mergebatch_v3851_qwen_retirement": "/merge-batch 2026-08-28 (v3.8.51): #11713 (Qwen Web retirement) own growth: open-sse/services/autoCombo/virtualFactory.ts 1132->1135 (+3, combining the Designer + Runtime retirement-guard filter into the single runtimeConnections predicate at the existing candidate-pool chokepoint, now excluding Qwen Web alongside Felo Web). Irreducible per-mechanism wiring, additive not a new branch. Covered by tests/unit/virtual-auto-combo.test.ts.", @@ -510,7 +483,15 @@ "src/shared/components/RequestLoggerDetail.tsx": 1018, "src/app/api/providers/[id]/test/route.ts": 1255, "src/lib/guardrails/videoBridgeRuntime.ts": 1009, - "_rebaseline_2026_08_28_mergebatch_v3851_ratchet_bank_reconcile": "/merge-batch 2026-08-28 (v3.8.51): boarding #11702 (fix/verify-ratchet-bank object-note comparator) surfaced a large stale `frozen`/`testFrozen` snapshot on PR #11702's own branch (forked before the 08-11 banking outage — see the object-valued `_rebaseline_2026_08_11_v3850_merge_storm_provider_registry` note above, the exact bug #11702 fixes in the verifier) — its conflicting block duplicated ~85 already-tracked files with sizes smaller than the current release tip, and still listed open-sse/executors/chatgpt-web.ts (deleted by the #11754 retirement). Resolved by re-measuring every file in the union of both sides directly on the boarded tree (split(\"\\n\").length, matching check-file-size.mjs) rather than trusting either stale snapshot; dropped the dead chatgpt-web.ts entry; kept the two genuinely-new entries PR #11702's branch had that this tip did not yet track (src/app/api/providers/[id]/test/route.ts, src/lib/guardrails/videoBridgeRuntime.ts, both re-measured). Same reconciliation applied to the testFrozen block above." + "_rebaseline_2026_08_28_mergebatch_v3851_ratchet_bank_reconcile": "/merge-batch 2026-08-28 (v3.8.51): boarding #11702 (fix/verify-ratchet-bank object-note comparator) surfaced a large stale `frozen`/`testFrozen` snapshot on PR #11702's own branch (forked before the 08-11 banking outage — see the object-valued `_rebaseline_2026_08_11_v3850_merge_storm_provider_registry` note above, the exact bug #11702 fixes in the verifier) — its conflicting block duplicated ~85 already-tracked files with sizes smaller than the current release tip, and still listed open-sse/executors/chatgpt-web.ts (deleted by the #11754 retirement). Resolved by re-measuring every file in the union of both sides directly on the boarded tree (split(\"\\n\").length, matching check-file-size.mjs) rather than trusting either stale snapshot; dropped the dead chatgpt-web.ts entry; kept the two genuinely-new entries PR #11702's branch had that this tip did not yet track (src/app/api/providers/[id]/test/route.ts, src/lib/guardrails/videoBridgeRuntime.ts, both re-measured). Same reconciliation applied to the testFrozen block above.", + "open-sse/executors/chatgpt-web.ts": 4213, + "_rebaseline_2026_08_11_v3850_merge_storm_provider_registry: DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legítima acima do cap; gateways.ts = god-file de catálogo de providers que cresceu com os PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o próprio PR #9421 foi o que quebrou o arquivo; sem split até o release, congelado no tamanho atual). Owner autorizou rebaseline com anotação (2026-08-11).": { + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1062, + "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051, + "src/shared/components/ModelSelectModal.tsx": 1138, + "src/shared/constants/providers/apikey/gateways.ts": 1250 + }, + "open-sse/executors/commandCode.ts": 1059 }, "_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.", "_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).", @@ -681,5 +662,7 @@ "_rebaseline_2026_08_25_11146_subscription_first_auto": "PR #11146 (@yourspraveen, subscription-first auto groupings auto/subscription+auto/thrifty): open-sse/services/autoCombo/virtualFactory.ts is a NEW file in this PR landing at 1128 lines (+2 margin) — two opt-in flat auto ids built on the established auto/best-free pattern (connectionBillingCatalog + subscriptionLadder pure functions). Frozen at merge size per owner-authorized rebaseline directive (2026-08-19, merge-batch Step 4); no further growth without split rationale.", "_rebaseline_2026_08_26_mergebatch_v3851_batch1": "/merge-batch 2026-08-26 (v3.8.51): three legitimate growths from this batch. #11448 src/app/api/providers/[id]/test/route.ts 1237->1262 (auto-test-on-create wiring). #11495 src/sse/services/auth.ts 3346->3376 (web-cookie health-sweep verify-only path). #11561 src/lib/cloudflaredTunnel.ts new named-tunnel mode, lands at 1078 (+78 over the 1000 new-file cap) for the CLOUDFLARED_CONFIG named-tunnel flow (login->create->route dns config parsing + readiness detection). Owner-authorized rebaseline per merge-batch Step 4 (2026-08-19 directive); no further growth without split rationale.", "_rebaseline_2026_08_26_mergebatch_v3851_batch2": "/merge-batch 2026-08-26 (v3.8.51) batch 2: three legitimate growths. #11083 src/shared/components/RequestLoggerDetail.tsx new-file cap, lands at 1018 (+18 over 1000) — copy-all button for request detail modal. #11631 src/shared/constants/providers/apikey/gateways.ts 1321->1330 (1min.ai gateway entry). #11628 src/sse/services/auth.ts 3376->3432 (credential-health isolation from model failures). Owner-authorized rebaseline per merge-batch Step 4 (2026-08-19 directive); no further growth without split rationale.", - "_rebaseline_2026_08_26_mergebatch_v3851_batch5": "/merge-batch 2026-08-26 (v3.8.51) batch 5: #11642 tests/integration/skills-pipeline.test.ts new regression test for the configured-provider-over-fallback search selection (#11524), lands at 1010 lines (+10 over the 1000 new-file testCap). Owner-authorized rebaseline per merge-batch Step 4 (2026-08-19 directive); no further growth without split rationale." + "_rebaseline_2026_08_26_mergebatch_v3851_batch5": "/merge-batch 2026-08-26 (v3.8.51) batch 5: #11642 tests/integration/skills-pipeline.test.ts new regression test for the configured-provider-over-fallback search selection (#11524), lands at 1010 lines (+10 over the 1000 new-file testCap). Owner-authorized rebaseline per merge-batch Step 4 (2026-08-19 directive); no further growth without split rationale.", + "_rebaseline_2026_06_30_v3842_release_chatgptweb_compression": "v3.8.42 cycle-close file-size reconciliation (DRIFT measured OK on each PR's base, stacked above frozen at the merge tip; fast-path PR->release/** does not run check:file-size). (1) open-sse/executors/chatgpt-web.ts 2870->3206 (+336 = #5531 portable SHA3-512 sentinel-PoW wiring with the native-vs-fallback digest path + #5536 GPT-5.5 Pro handoff branch; the pure Keccak-f[1600] fallback itself already lives in the separate leaf open-sse/utils/sha3-512.ts — the executor growth is the cohesive call-site/handoff logic, not extractable without hiding the sentinel chokepoint). (2) tests/unit/chatgpt-web.test.ts 2855->3159 (+304 = #5536 GPT-5.5 Pro handoff coverage; pair-file with its executor). (3) open-sse/services/compression/strategySelector.ts 997->1022 (+25 = #5527 T02 honest default-on pipeline inflation guard wiring at the existing finalizeStackedResult choke). All cohesive at existing chokepoints; covered by tests/unit/chatgpt-web-sha3-boringssl-5531.test.ts, chatgpt-web.test.ts (GPT-5.5 Pro), compression-pipeline-inflation-guard.test.ts.", + "open-sse/executors/chatgpt-web.ts": "3241" } diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 3afd4017f5..ec657bda20 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -196,7 +196,8 @@ "_rebaseline_2026_07_28_v3849_release_preflight": "6762 -> 7666 (+904). Fechamento do ciclo v3.8.49: gzip dos entrypoints bin/*.mjs (size-limit + @size-limit/file) cresceu com o que os merges do ciclo puxam transitivamente para o CLI (novos provedores — 271->290, seletor de protocolo por conexão #8861, catálogos de busca #8814, resiliência). Crescimento legítimo de ciclo, medido localmente com `npm run check:bundle-size` = 7666 (gzip determinístico, bate com o CI). Encolher é dívida separada.", "_rebaseline_2026_08_09_v3850_release_close": "7666 -> 8045 (+379 gzip bytes, +4.9%). Release v3.8.50 close reconciliation measured twice with the real size-limit + @size-limit/file path on tip e0ce95c592. Per-entry measurements remain below their absolute budgets: omniroute.mjs 4380/15000, mcp-server.mjs 1195/5000, nodeRuntimeSupport.mjs 887/8000, reset-password.mjs 1583/6000. The growth accumulated through legitimate CLI/runtime work in this cycle, including global-install ESM alias resolution, Termux cache preparation, and MCP stdio startup hardening; no entrypoint is near its absolute ceiling. The direction:down ratchet stays blocking from this exact measured tip.", "_rebaseline_2026_08_24_ci_green_gates_f95b03d7": "8045 -> 8461 (+416 gzip bytes, +5.2%). CI run 32786966560 (release PR #8875, tip f95b03d7) measured bundleSize=8461 via check:bundle-size --ratchet, above the 8045 baseline left at the v3.8.50 close. The growth comes from the post-freeze back-merge cycle landing in the CLI entrypoints (Synthetic + Kilo Gateway providers, kilo-gateway routing surface). Re-baseline per the gate's own instruction (legitimate cycle growth); shrinking the entrypoints remains separate debt; direction:down ratchet stays blocking from this measured tip.", - "_rebaseline_2026_08_27_v3851_volatile_env_warning_11437": "8461 -> 8653 (+192 gzip bytes, +2.3%). Exact paired size-limit measurements on the VPS compared f95b03d709 with release/v3.8.51: only bin/omniroute.mjs changed, 4700 -> 4892; the other three entries remained 1195/983/1583. The growth originates in 943b9aaa84 (#11437), which warns users before a package-local .env is lost on the next global install. The CLI entry remains 4892/15000 bytes (32.6% of its absolute budget). Legitimate bug-fix growth; shrinking stays separate debt and direction:down remains blocking from this measured tip." + "_rebaseline_2026_08_27_v3851_volatile_env_warning_11437": "8461 -> 8653 (+192 gzip bytes, +2.3%). Exact paired size-limit measurements on the VPS compared f95b03d709 with release/v3.8.51: only bin/omniroute.mjs changed, 4700 -> 4892; the other three entries remained 1195/983/1583. The growth originates in 943b9aaa84 (#11437), which warns users before a package-local .env is lost on the next global install. The CLI entry remains 4892/15000 bytes (32.6% of its absolute budget). Legitimate bug-fix growth; shrinking stays separate debt and direction:down remains blocking from this measured tip.", + "_rebaseline_2026_08_25_v3850_release_preflight": "8045 -> 8461 (+416 gzip bytes, +5.2%). Release v3.8.50 pre-flight, measured with the real size-limit + @size-limit/file path on tip e3e188e993 (the 08-09 rebaseline was taken on e0ce95c592 and the branch kept moving). The growth is concentrated in the two entrypoints the cycle actually touched: omniroute.mjs 4380->4700 and nodeRuntimeSupport.mjs 887->983 — #11039 (native Bun backend + Dockerfile.bun), #10870 (report .env lines that never take effect) and #10101 (strip inline comments when parsing .env values). mcp-server.mjs (1195) and reset-password.mjs (1583) are unchanged. Every entry stays far below its absolute budget (4700/15000, 1195/5000, 983/8000, 1583/6000). Legitimate cycle growth; shrinking is separate debt. The direction:down ratchet stays blocking from this measured tip." }, "openapiBreaking": { "value": 4, diff --git a/config/quality/test-masking-allowlist.json b/config/quality/test-masking-allowlist.json index ba03e64742..1876b8c677 100644 --- a/config/quality/test-masking-allowlist.json +++ b/config/quality/test-masking-allowlist.json @@ -174,7 +174,9 @@ "reason": "v3.8.50 #9126 (commit 8fac6bcd48): pluginWorker.ts, sandbox.ts e signing.ts foram removidos por completo (\"zero importers confirmed\") — o subsistema de sandbox de plugins com worker-thread nunca foi ligado a nenhum consumidor. O teste era source-scan sobre pluginWorker.ts (ver docstring do arquivo deletado); sem o arquivo-fonte não há mais o que testar. OMNIROUTE_PLUGINS_ALLOW_EXEC também foi removido de .env.example e da doc na mesma release. Sem substituto porque a feature foi extinta, não migrada." }, "tests/unit/plugins-sandbox.test.ts": { - "sourceRemoved": ["src/lib/plugins/sandbox.ts"], + "sourceRemoved": [ + "src/lib/plugins/sandbox.ts" + ], "reason": "v3.8.50 #9126 (commit 8fac6bcd48): sandbox.ts foi removido por completo junto com pluginWorker.ts e signing.ts (\"zero importers confirmed\", subsistema de sandbox de plugins nunca ligado a nenhum consumidor). O teste cobria SandboxLevel/getSandboxLabel exportados por sandbox.ts; sem o arquivo-fonte não há mais símbolo a testar. Mesma causa-raiz de tests/unit/plugin-sandbox-permissions.test.ts nesta entrada." }, "tests/unit/gemini-3-5-flash-thinking.test.ts": { @@ -250,5 +252,8 @@ "open-sse/services/__tests__/tierResolver.test.ts": "v3.8.49 #7866: refactor(qwen) remove o provider OAuth legado — o teste \"classifies Qwen as free\" e a entrada de qwen na lista do batch saíram junto com o provider, e os índices do batch desceram de 10 para 9 elementos (net 61→59). Superfície extinta, não enfraquecimento. Verificado legítimo. Prune após v3.8.49 mergear para main.", "tests/unit/plugins-welcome-banner-e2e.test.ts": "v3.8.50 #9126 (commit 8fac6bcd48): o teste único 'BUILTIN_EVENTS has all 14 events' (13 asserts .ok/.equal) foi reestruturado em 3 testes mais específicos — 'contains only emitted/public events' (assert.deepEqual da lista completa), 'does not advertise dead events' (7 asserts .equal(false) para eventos sem emissor real: onModelSelect/onComboResolve/onRateLimit/onQuotaExhaust/onProviderError/onStreamStart/onStreamEnd) e 'lifecycle events remain represented' (4 asserts .ok). Contrato mais forte (agora também nega presença dos eventos mortos), não mais fraco — a contagem líquida cai (73→61) porque o assert.deepEqual único substitui múltiplos assert.ok redundantes com a mesma cobertura. Asserts restruturados, não removidos sem substituição. Verificado legítimo.", "tests/unit/web-tools-translation-2820.test.ts": "v3.8.50 #9343 (commit d969555417): fix(security) exige envelope explicito — JSON puro NAO deve mais ser promovido a tool_calls. Os 5 testes foram REESCRITOS para o contrato oposto (antes: 'promove e valida name/arguments'; agora: 'toolCalls === null e content preservado'), o que naturalmente usa menos asserts: verificar a NAO-promocao custa 2 asserts, verificar o objeto promovido custava 4. Contrato mais restritivo, nao mais fraco (39->35). Verificado legitimo — a inversao esta explicita nos proprios nomes dos testes ('does NOT promote ... (#9343)').", - "tests/unit/deepseek-web-tools-execute-2820.test.ts": "v3.8.50 ed661f2126 (alinhamento ao #9343): o teste 'parses bare JSON reply into OpenAI tool_calls' foi reescrito para o contrato INVERTIDO do fix de seguranca #9343 — JSON puro sem envelope NAO deve mais ser promovido. Verificar a nao-promocao custa 3 asserts (finish_reason stop, sem tool_calls, content preservado verbatim) onde validar o objeto promovido custava 5 (23->21). Mesma classe da entrada web-tools-translation-2820 acima. Contrato mais restritivo, nao mais fraco." + "tests/unit/deepseek-web-tools-execute-2820.test.ts": "v3.8.50 ed661f2126 (alinhamento ao #9343): o teste 'parses bare JSON reply into OpenAI tool_calls' foi reescrito para o contrato INVERTIDO do fix de seguranca #9343 — JSON puro sem envelope NAO deve mais ser promovido. Verificar a nao-promocao custa 3 asserts (finish_reason stop, sem tool_calls, content preservado verbatim) onde validar o objeto promovido custava 5 (23->21). Mesma classe da entrada web-tools-translation-2820 acima. Contrato mais restritivo, nao mais fraco.", + "tests/unit/startup-stale-cooldown-recovery.test.ts": "v3.8.50 #11355: o contrato do startup foi INVERTIDO — clearStaleCrashCooldowns deixou de limpar todo cooldown transiente e passa a limpar apenas os EXPIRADOS/não-parseáveis, preservando um rateLimitedUntil futuro (quota semanal/mensal que era apagada a cada restart). Os asserts das pós-condições da limpeza deixaram de existir e foram substituídos pelos da preservação (cleared === 0 + campos intactos), net 24→21. Asserts migrados ao novo contrato, não enfraquecidos. Prune após v3.8.50 mergear para main.", + "tests/unit/8134-github-t5-fallback-filter.test.ts": "v3.8.50 #11280: #10952 adicionou claude-opus-4.6 ao catálogo do github, então o papel de 'tier provadamente ausente' migrou para claude-opus-4-6-thinking. Os asserts desenrolados de 2 hops viraram um LOOP de 3 hops (cada hop assere pertencimento ao catálogo + ausência do tier morto) mais uma igualdade exata no hop final — cobertura ESTRITAMENTE MAIOR com contagem estática menor (10→9). Verificado legítimo. Prune após v3.8.50 mergear para main.", + "tests/unit/model-capabilities-registry.test.ts": "v3.8.50 2764812ee4 (eliminate Gemini 3.5 Flash): os IDs gemini-3.5-flash{,-extra-low,-low} e gemini-3-flash-agent foram RETIRADOS do MODEL_SPECS, então os 6 asserts de capabilities por ID (contextWindow/maxOutputTokens/supportsThinking/Tools/Vision) deixaram de ter objeto e foram substituídos pelo assert da ausência (MODEL_SPECS[id] === undefined), net 77→72. Superfície aposentada, não mascaramento. Prune após v3.8.50 mergear para main." } diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a60f51f53e..91bfdef1e9 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -5774,6 +5774,32 @@ paths: "200": description: Circuit breakers reset + /api/health: + get: + tags: [System] + summary: Liveness probe + description: >- + Unauthenticated liveness probe. Returns `{ status, timestamp }` and nothing + else — version, uptime and memory stay behind the authenticated + `GET /api/monitoring/health`. Sent with + `Cache-Control: no-store, no-cache, must-revalidate`. + security: [] + responses: + "200": + description: Service is alive. + content: + application/json: + schema: + type: object + required: [status, timestamp] + properties: + status: + type: string + enum: [ok] + timestamp: + type: string + format: date-time + /api/monitoring/health: get: tags: [System] @@ -7592,6 +7618,145 @@ paths: $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalError" + /api/v1/voices: + get: + tags: [Audio] + summary: List ElevenLabs voices + description: >- + Proxies `GET https://api.elevenlabs.io/v1/voices` using the stored + `elevenlabs` provider credentials (the caller never sends `xi-api-key`). + The incoming query string is forwarded unchanged. + security: + - BearerAuth: [] + responses: + "200": + description: ElevenLabs voice list, relayed verbatim. + "401": + description: No usable `elevenlabs` credential is configured. + "429": + description: Every configured `elevenlabs` credential is rate limited. + "500": + $ref: "#/components/responses/InternalError" + /api/v1/speech-to-text: + post: + tags: [Audio] + summary: ElevenLabs speech-to-text + description: >- + Streams the request body to `POST https://api.elevenlabs.io/v1/speech-to-text` + using the stored `elevenlabs` provider credentials. `content-type` and + `accept` are forwarded; the upstream body is relayed unchanged. + security: + - BearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + "200": + description: Transcription payload, relayed verbatim from ElevenLabs. + "401": + description: No usable `elevenlabs` credential is configured. + "429": + description: Every configured `elevenlabs` credential is rate limited. + "500": + $ref: "#/components/responses/InternalError" + /api/v1/text-to-speech/{voiceId}: + post: + tags: [Audio] + summary: ElevenLabs text-to-speech + description: >- + Streams the request body to + `POST https://api.elevenlabs.io/v1/text-to-speech/{voiceId}` using the stored + `elevenlabs` provider credentials. `voiceId` must match `^[A-Za-z0-9_-]+$` + or the request is rejected with 400 before any upstream call. + security: + - BearerAuth: [] + parameters: + - name: voiceId + in: path + required: true + schema: + type: string + pattern: "^[A-Za-z0-9_-]+$" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + text: + type: string + responses: + "200": + description: Audio stream, relayed verbatim from ElevenLabs. + "400": + description: Invalid ElevenLabs voice ID. + "401": + description: No usable `elevenlabs` credential is configured. + "429": + description: Every configured `elevenlabs` credential is rate limited. + "500": + $ref: "#/components/responses/InternalError" + /api/v1/explain/routing: + get: + tags: [Telemetry] + summary: Routing explainability snapshot + description: >- + Returns the most recent routing events (bounded in-memory ring buffer) plus + the per-provider/model quality snapshot from `open-sse/services/routing`. + Routing metadata only — never prompts, bodies, headers or credentials. + Auth mirrors `/api/v1/combos`: a valid Bearer API key or a dashboard + session; with `REQUIRE_API_KEY=false` anonymous reads are allowed. + security: + - BearerAuth: [] + parameters: + - name: limit + in: query + required: false + description: Events/quality rows to return; clamped to 1–500 (default 50). + schema: + type: integer + minimum: 1 + maximum: 500 + default: 50 + responses: + "200": + description: Routing explain payload. + content: + application/json: + schema: + type: object + properties: + object: + type: string + enum: [routing_explain] + sinks: + type: array + items: + type: string + otelEnabled: + type: boolean + events: + type: array + items: + type: object + quality: + type: array + items: + type: object + otel: + type: object + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" /api/v1/providers/suggested-models: get: tags: diff --git a/electron/package.json b/electron/package.json index f9b7a37b67..bfd803a045 100644 --- a/electron/package.json +++ b/electron/package.json @@ -67,6 +67,7 @@ "lib/resolveServerEntry.js", "lib/resolveNodeHelper.js", "lib/windowLifecycle.js", + "lib/loginHeaderCapture.js", "lib/resolveRemoteServerUrl.js", "lib/remoteServerPreferences.js", "lib/serverReadiness.js", @@ -79,6 +80,14 @@ { "from": "../.build/electron-standalone", "to": "app", + "filter": [ + "**/*", + "node_modules/**/*" + ] + }, + { + "from": "../.build/electron-standalone/node_modules", + "to": "app/node_modules", "filter": [ "**/*" ] diff --git a/open-sse/executors/vertexMedia.ts b/open-sse/executors/vertexMedia.ts index 390b087a32..4e2c02f20c 100644 --- a/open-sse/executors/vertexMedia.ts +++ b/open-sse/executors/vertexMedia.ts @@ -1,346 +1,346 @@ -/** - * Vertex AI media generation client. - * - * Google's Vertex AI serves speech (Gemini TTS), transcription (Gemini), music - * (Lyria) and video (Veo) — but through the same `aiplatform.googleapis.com` - * surface that the chat executor authenticates against, NOT through the - * third-party media registries (kie/suno/deepgram/…). This module reuses the - * Vertex chat executor's auth (Service Account JSON → OAuth bearer, or Express - * API key) and implements the verified per-model contracts: - * - * - Speech: `{model}:generateContent` + responseModalities:["AUDIO"] → PCM L16 → WAV - * - Transcription: `{model}:generateContent` with inline audio + text prompt → text - * - Music (Lyria): `{model}:predict` → predictions[0].bytesBase64Encoded (WAV) - * - Video (Veo): `{model}:predictLongRunning` → poll `{model}:fetchPredictOperation` - * → response.videos[0].bytesBase64Encoded (MP4) - */ - -import { Buffer } from "node:buffer"; -import { sleep } from "../utils/sleep.ts"; -import { - parseSAFromApiKey, - getAccessToken, - looksLikeServiceAccountJson, - isExpressApiKey, -} from "./vertex.ts"; - -export interface VertexMediaCredentials { - apiKey?: string | null; - accessToken?: string | null; - providerSpecificData?: Record | null; -} - -interface ResolvedVertexAuth { - project: string; - region: string; - bearerToken: string | null; - expressKey: string | null; -} - -const DEFAULT_REGION = "us-central1"; - -function resolveRegion(credentials: VertexMediaCredentials | null | undefined): string { - const psd = credentials?.providerSpecificData; - if (psd && typeof psd === "object") { - const region = (psd as Record).region; - if (typeof region === "string" && region.trim().length > 0) return region.trim(); - } - return DEFAULT_REGION; -} - -async function resolveVertexAuth( - credentials: VertexMediaCredentials | null | undefined -): Promise { - const apiKey = typeof credentials?.apiKey === "string" ? credentials.apiKey.trim() : ""; - const region = resolveRegion(credentials); - let bearerToken = - typeof credentials?.accessToken === "string" && credentials.accessToken.trim().length > 0 - ? credentials.accessToken.trim() - : null; - let project = ""; - let expressKey: string | null = null; - - if (looksLikeServiceAccountJson(apiKey)) { - const sa = parseSAFromApiKey(apiKey); - project = typeof sa.project_id === "string" ? sa.project_id : ""; - if (!bearerToken) bearerToken = await getAccessToken(sa); - } else if (isExpressApiKey(apiKey)) { - expressKey = apiKey; - } - - return { project, region, bearerToken, expressKey }; -} - -/** - * Build the request URL + headers for a Vertex publisher-model action. - * SA path → project-scoped regional endpoint + Bearer auth. - * Express path (best-effort) → project-less global publisher endpoint + ?key=. - */ -function buildModelRequest( - auth: ResolvedVertexAuth, - model: string, - action: string -): { url: string; headers: Record } { - const headers: Record = { "Content-Type": "application/json" }; - - if (auth.bearerToken && auth.project) { - headers["Authorization"] = `Bearer ${auth.bearerToken}`; - return { - url: `https://${auth.region}-aiplatform.googleapis.com/v1/projects/${auth.project}/locations/${auth.region}/publishers/google/models/${model}:${action}`, - headers, - }; - } - - if (auth.expressKey) { - return { - url: `https://aiplatform.googleapis.com/v1/publishers/google/models/${model}:${action}?key=${encodeURIComponent( - auth.expressKey - )}`, - headers, - }; - } - - throw new Error( - "Vertex AI requires a Service Account JSON (with project_id) or a Vertex AI Express API key" - ); -} - -interface VertexHttpError extends Error { - status?: number; -} - -async function vertexError(res: Response): Promise { - let detail = ""; - try { - detail = await res.text(); - } catch { - /* ignore */ - } - let message = `Vertex AI error (${res.status})`; - if (detail) { - try { - const parsed = JSON.parse(detail); - message = parsed?.error?.message || message; - } catch { - message = detail.slice(0, 300); - } - } - const err = new Error(message) as VertexHttpError; - err.status = res.status; - return err; -} - -/** Wrap raw little-endian 16-bit PCM mono samples in a minimal WAV container. */ -export function pcmToWav( - pcm: Buffer, - sampleRate = 24000, - channels = 1, - bitsPerSample = 16 -): Buffer { - const blockAlign = (channels * bitsPerSample) / 8; - const byteRate = sampleRate * blockAlign; - const header = Buffer.alloc(44); - header.write("RIFF", 0); - header.writeUInt32LE(36 + pcm.length, 4); - header.write("WAVE", 8); - header.write("fmt ", 12); - header.writeUInt32LE(16, 16); - header.writeUInt16LE(1, 20); // PCM - header.writeUInt16LE(channels, 22); - header.writeUInt32LE(sampleRate, 24); - header.writeUInt32LE(byteRate, 28); - header.writeUInt16LE(blockAlign, 32); - header.writeUInt16LE(bitsPerSample, 34); - header.write("data", 36); - header.writeUInt32LE(pcm.length, 40); - return Buffer.concat([header, pcm]); -} - +/** + * Vertex AI media generation client. + * + * Google's Vertex AI serves speech (Gemini TTS), transcription (Gemini), music + * (Lyria) and video (Veo) — but through the same `aiplatform.googleapis.com` + * surface that the chat executor authenticates against, NOT through the + * third-party media registries (kie/suno/deepgram/…). This module reuses the + * Vertex chat executor's auth (Service Account JSON → OAuth bearer, or Express + * API key) and implements the verified per-model contracts: + * + * - Speech: `{model}:generateContent` + responseModalities:["AUDIO"] → PCM L16 → WAV + * - Transcription: `{model}:generateContent` with inline audio + text prompt → text + * - Music (Lyria): `{model}:predict` → predictions[0].bytesBase64Encoded (WAV) + * - Video (Veo): `{model}:predictLongRunning` → poll `{model}:fetchPredictOperation` + * → response.videos[0].bytesBase64Encoded (MP4) + */ + +import { Buffer } from "node:buffer"; +import { sleep } from "../utils/sleep.ts"; +import { + parseSAFromApiKey, + getAccessToken, + looksLikeServiceAccountJson, + isExpressApiKey, +} from "./vertex.ts"; + +export interface VertexMediaCredentials { + apiKey?: string | null; + accessToken?: string | null; + providerSpecificData?: Record | null; +} + +interface ResolvedVertexAuth { + project: string; + region: string; + bearerToken: string | null; + expressKey: string | null; +} + +const DEFAULT_REGION = "us-central1"; + +function resolveRegion(credentials: VertexMediaCredentials | null | undefined): string { + const psd = credentials?.providerSpecificData; + if (psd && typeof psd === "object") { + const region = (psd as Record).region; + if (typeof region === "string" && region.trim().length > 0) return region.trim(); + } + return DEFAULT_REGION; +} + +async function resolveVertexAuth( + credentials: VertexMediaCredentials | null | undefined +): Promise { + const apiKey = typeof credentials?.apiKey === "string" ? credentials.apiKey.trim() : ""; + const region = resolveRegion(credentials); + let bearerToken = + typeof credentials?.accessToken === "string" && credentials.accessToken.trim().length > 0 + ? credentials.accessToken.trim() + : null; + let project = ""; + let expressKey: string | null = null; + + if (looksLikeServiceAccountJson(apiKey)) { + const sa = parseSAFromApiKey(apiKey); + project = typeof sa.project_id === "string" ? sa.project_id : ""; + if (!bearerToken) bearerToken = await getAccessToken(sa); + } else if (isExpressApiKey(apiKey)) { + expressKey = apiKey; + } + + return { project, region, bearerToken, expressKey }; +} + +/** + * Build the request URL + headers for a Vertex publisher-model action. + * SA path → project-scoped regional endpoint + Bearer auth. + * Express path (best-effort) → project-less global publisher endpoint + ?key=. + */ +function buildModelRequest( + auth: ResolvedVertexAuth, + model: string, + action: string +): { url: string; headers: Record } { + const headers: Record = { "Content-Type": "application/json" }; + + if (auth.bearerToken && auth.project) { + headers["Authorization"] = `Bearer ${auth.bearerToken}`; + return { + url: `https://${auth.region}-aiplatform.googleapis.com/v1/projects/${auth.project}/locations/${auth.region}/publishers/google/models/${model}:${action}`, + headers, + }; + } + + if (auth.expressKey) { + return { + url: `https://aiplatform.googleapis.com/v1/publishers/google/models/${model}:${action}?key=${encodeURIComponent( + auth.expressKey + )}`, + headers, + }; + } + + throw new Error( + "Vertex AI requires a Service Account JSON (with project_id) or a Vertex AI Express API key" + ); +} + +interface VertexHttpError extends Error { + status?: number; +} + +async function vertexError(res: Response): Promise { + let detail = ""; + try { + detail = await res.text(); + } catch { + /* ignore */ + } + let message = `Vertex AI error (${res.status})`; + if (detail) { + try { + const parsed = JSON.parse(detail); + message = parsed?.error?.message || message; + } catch { + message = detail.slice(0, 300); + } + } + const err = new Error(message) as VertexHttpError; + err.status = res.status; + return err; +} + +/** Wrap raw little-endian 16-bit PCM mono samples in a minimal WAV container. */ +export function pcmToWav( + pcm: Buffer, + sampleRate = 24000, + channels = 1, + bitsPerSample = 16 +): Buffer { + const blockAlign = (channels * bitsPerSample) / 8; + const byteRate = sampleRate * blockAlign; + const header = Buffer.alloc(44); + header.write("RIFF", 0); + header.writeUInt32LE(36 + pcm.length, 4); + header.write("WAVE", 8); + header.write("fmt ", 12); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); // PCM + header.writeUInt16LE(channels, 22); + header.writeUInt32LE(sampleRate, 24); + header.writeUInt32LE(byteRate, 28); + header.writeUInt16LE(blockAlign, 32); + header.writeUInt16LE(bitsPerSample, 34); + header.write("data", 36); + header.writeUInt32LE(pcm.length, 40); + return Buffer.concat([header, pcm]); +} + export function parsePcmSampleRate(mimeType: string | undefined): number { - if (!mimeType) return 24000; - const match = /rate=(\d+)/i.exec(mimeType); - return match ? parseInt(match[1], 10) : 24000; -} - + if (!mimeType) return 24000; + const match = /rate=(\d+)/i.exec(mimeType); + return match ? parseInt(match[1], 10) : 24000; +} + export function extractInlineAudio( - data: unknown -): { base64: string; mimeType: string } | null { - const parts = (data as { candidates?: Array<{ content?: { parts?: unknown[] } }> })?.candidates?.[0] - ?.content?.parts; - if (!Array.isArray(parts)) return null; - for (const part of parts) { - const inline = (part as { inlineData?: { data?: unknown; mimeType?: unknown } })?.inlineData; - if (inline && typeof inline.data === "string" && inline.data.length > 0) { - return { - base64: inline.data, - mimeType: typeof inline.mimeType === "string" ? inline.mimeType : "audio/L16;rate=24000", - }; - } - } - return null; -} - -function extractText(data: unknown): string { - const parts = (data as { candidates?: Array<{ content?: { parts?: unknown[] } }> })?.candidates?.[0] - ?.content?.parts; - if (!Array.isArray(parts)) return ""; - return parts - .map((part) => (part as { text?: unknown })?.text) - .filter((text): text is string => typeof text === "string") - .join("") - .trim(); -} - -/** Gemini TTS → WAV audio buffer. */ -export async function vertexGenerateSpeech( - credentials: VertexMediaCredentials, - options: { model: string; input: string; voice?: string } -): Promise<{ audio: Buffer; contentType: string }> { - const auth = await resolveVertexAuth(credentials); - const { url, headers } = buildModelRequest(auth, options.model, "generateContent"); - const payload = { - contents: [{ role: "user", parts: [{ text: options.input }] }], - generationConfig: { - responseModalities: ["AUDIO"], - speechConfig: { - voiceConfig: { - prebuiltVoiceConfig: { voiceName: options.voice && options.voice.trim() ? options.voice.trim() : "Kore" }, - }, - }, - }, - }; - const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) }); - if (!res.ok) throw await vertexError(res); - const data = await res.json(); - const inline = extractInlineAudio(data); - if (!inline) throw new Error("Vertex TTS returned no audio content"); - const pcm = Buffer.from(inline.base64, "base64"); + data: unknown +): { base64: string; mimeType: string } | null { + const parts = (data as { candidates?: Array<{ content?: { parts?: unknown[] } }> })?.candidates?.[0] + ?.content?.parts; + if (!Array.isArray(parts)) return null; + for (const part of parts) { + const inline = (part as { inlineData?: { data?: unknown; mimeType?: unknown } })?.inlineData; + if (inline && typeof inline.data === "string" && inline.data.length > 0) { + return { + base64: inline.data, + mimeType: typeof inline.mimeType === "string" ? inline.mimeType : "audio/L16;rate=24000", + }; + } + } + return null; +} + +function extractText(data: unknown): string { + const parts = (data as { candidates?: Array<{ content?: { parts?: unknown[] } }> })?.candidates?.[0] + ?.content?.parts; + if (!Array.isArray(parts)) return ""; + return parts + .map((part) => (part as { text?: unknown })?.text) + .filter((text): text is string => typeof text === "string") + .join("") + .trim(); +} + +/** Gemini TTS → WAV audio buffer. */ +export async function vertexGenerateSpeech( + credentials: VertexMediaCredentials, + options: { model: string; input: string; voice?: string } +): Promise<{ audio: Buffer; contentType: string }> { + const auth = await resolveVertexAuth(credentials); + const { url, headers } = buildModelRequest(auth, options.model, "generateContent"); + const payload = { + contents: [{ role: "user", parts: [{ text: options.input }] }], + generationConfig: { + responseModalities: ["AUDIO"], + speechConfig: { + voiceConfig: { + prebuiltVoiceConfig: { voiceName: options.voice && options.voice.trim() ? options.voice.trim() : "Kore" }, + }, + }, + }, + }; + const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) }); + if (!res.ok) throw await vertexError(res); + const data = await res.json(); + const inline = extractInlineAudio(data); + if (!inline) throw new Error("Vertex TTS returned no audio content"); + const pcm = Buffer.from(inline.base64, "base64"); return { audio: pcmToWav(pcm, parsePcmSampleRate(inline.mimeType)), contentType: "audio/wav" }; -} - -/** Gemini transcription (audio → text). `audioBase64` is the raw file bytes, base64-encoded. */ -export async function vertexTranscribe( - credentials: VertexMediaCredentials, - options: { model: string; audioBase64: string; mimeType?: string; prompt?: string; language?: string } -): Promise { - const auth = await resolveVertexAuth(credentials); - const { url, headers } = buildModelRequest(auth, options.model, "generateContent"); - const instruction = - options.prompt && options.prompt.trim().length > 0 - ? options.prompt.trim() - : `Transcribe this audio verbatim. Output only the spoken words${ - options.language ? ` (language: ${options.language})` : "" - }, with no commentary.`; - const payload = { - contents: [ - { - role: "user", - parts: [ - { text: instruction }, - { inlineData: { mimeType: options.mimeType || "audio/wav", data: options.audioBase64 } }, - ], - }, - ], - }; - const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) }); - if (!res.ok) throw await vertexError(res); - return extractText(await res.json()); -} - -/** Lyria music generation → { base64 WAV, format }. */ -export async function vertexGenerateMusic( - credentials: VertexMediaCredentials, - options: { model?: string; prompt: string; negativePrompt?: string; sampleCount?: number; seed?: number } -): Promise<{ base64: string; format: string }> { - const auth = await resolveVertexAuth(credentials); - const model = options.model && options.model.trim() ? options.model.trim() : "lyria-002"; - const { url, headers } = buildModelRequest(auth, model, "predict"); - const instance: Record = { prompt: options.prompt }; - if (options.negativePrompt) instance.negative_prompt = options.negativePrompt; - if (typeof options.seed === "number") instance.seed = options.seed; - const parameters: Record = {}; - if (typeof options.sampleCount === "number") parameters.sample_count = options.sampleCount; - const res = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify({ instances: [instance], parameters }), - }); - if (!res.ok) throw await vertexError(res); - const data = await res.json(); - const base64 = (data as { predictions?: Array<{ bytesBase64Encoded?: unknown }> })?.predictions?.[0] - ?.bytesBase64Encoded; - if (typeof base64 !== "string" || base64.length === 0) { - throw new Error("Vertex Lyria returned no audio"); - } - return { base64, format: "wav" }; -} - -/** Veo video generation (async long-running) → { base64 MP4 or gcsUri, format }. */ -export async function vertexGenerateVideo( - credentials: VertexMediaCredentials, - options: { - model: string; - prompt: string; - aspectRatio?: string; - durationSeconds?: number; - sampleCount?: number; - negativePrompt?: string; - image?: { bytesBase64Encoded: string; mimeType: string }; - pollIntervalMs?: number; - maxWaitMs?: number; - } -): Promise<{ base64?: string; url?: string; format: string }> { - const auth = await resolveVertexAuth(credentials); - const submit = buildModelRequest(auth, options.model, "predictLongRunning"); - - const instance: Record = { prompt: options.prompt }; - if (options.image) instance.image = options.image; - const parameters: Record = { - sampleCount: typeof options.sampleCount === "number" ? options.sampleCount : 1, - }; - if (options.aspectRatio) parameters.aspectRatio = options.aspectRatio; - if (typeof options.durationSeconds === "number") parameters.durationSeconds = options.durationSeconds; - if (options.negativePrompt) parameters.negativePrompt = options.negativePrompt; - - const submitRes = await fetch(submit.url, { - method: "POST", - headers: submit.headers, - body: JSON.stringify({ instances: [instance], parameters }), - }); - if (!submitRes.ok) throw await vertexError(submitRes); - const op = await submitRes.json(); - const operationName = (op as { name?: unknown })?.name; - if (typeof operationName !== "string" || operationName.length === 0) { - throw new Error("Vertex Veo did not return an operation name"); - } - - const poll = buildModelRequest(auth, options.model, "fetchPredictOperation"); - const intervalMs = options.pollIntervalMs && options.pollIntervalMs > 0 ? options.pollIntervalMs : 10000; - const maxWaitMs = options.maxWaitMs && options.maxWaitMs > 0 ? options.maxWaitMs : 5 * 60 * 1000; - const deadline = Date.now() + maxWaitMs; - - while (Date.now() < deadline) { - await sleep(intervalMs); - const pollRes = await fetch(poll.url, { - method: "POST", - headers: poll.headers, - body: JSON.stringify({ operationName }), - }); - if (!pollRes.ok) throw await vertexError(pollRes); - const pollData = await pollRes.json(); - if ((pollData as { done?: unknown })?.done) { - const opError = (pollData as { error?: { message?: unknown } })?.error; - if (opError) throw new Error(String(opError.message || "Veo operation failed")); - const videos = (pollData as { response?: { videos?: unknown } })?.response?.videos; - const video = Array.isArray(videos) ? (videos[0] as Record) : null; - if (video && typeof video.bytesBase64Encoded === "string") { - return { base64: video.bytesBase64Encoded, format: "mp4" }; - } - if (video && typeof video.gcsUri === "string") { - return { url: video.gcsUri, format: "mp4" }; - } - throw new Error("Veo operation completed but returned no video"); - } - } - throw new Error("Vertex Veo video generation timed out"); -} +} + +/** Gemini transcription (audio → text). `audioBase64` is the raw file bytes, base64-encoded. */ +export async function vertexTranscribe( + credentials: VertexMediaCredentials, + options: { model: string; audioBase64: string; mimeType?: string; prompt?: string; language?: string } +): Promise { + const auth = await resolveVertexAuth(credentials); + const { url, headers } = buildModelRequest(auth, options.model, "generateContent"); + const instruction = + options.prompt && options.prompt.trim().length > 0 + ? options.prompt.trim() + : `Transcribe this audio verbatim. Output only the spoken words${ + options.language ? ` (language: ${options.language})` : "" + }, with no commentary.`; + const payload = { + contents: [ + { + role: "user", + parts: [ + { text: instruction }, + { inlineData: { mimeType: options.mimeType || "audio/wav", data: options.audioBase64 } }, + ], + }, + ], + }; + const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) }); + if (!res.ok) throw await vertexError(res); + return extractText(await res.json()); +} + +/** Lyria music generation → { base64 WAV, format }. */ +export async function vertexGenerateMusic( + credentials: VertexMediaCredentials, + options: { model?: string; prompt: string; negativePrompt?: string; sampleCount?: number; seed?: number } +): Promise<{ base64: string; format: string }> { + const auth = await resolveVertexAuth(credentials); + const model = options.model && options.model.trim() ? options.model.trim() : "lyria-002"; + const { url, headers } = buildModelRequest(auth, model, "predict"); + const instance: Record = { prompt: options.prompt }; + if (options.negativePrompt) instance.negative_prompt = options.negativePrompt; + if (typeof options.seed === "number") instance.seed = options.seed; + const parameters: Record = {}; + if (typeof options.sampleCount === "number") parameters.sample_count = options.sampleCount; + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ instances: [instance], parameters }), + }); + if (!res.ok) throw await vertexError(res); + const data = await res.json(); + const base64 = (data as { predictions?: Array<{ bytesBase64Encoded?: unknown }> })?.predictions?.[0] + ?.bytesBase64Encoded; + if (typeof base64 !== "string" || base64.length === 0) { + throw new Error("Vertex Lyria returned no audio"); + } + return { base64, format: "wav" }; +} + +/** Veo video generation (async long-running) → { base64 MP4 or gcsUri, format }. */ +export async function vertexGenerateVideo( + credentials: VertexMediaCredentials, + options: { + model: string; + prompt: string; + aspectRatio?: string; + durationSeconds?: number; + sampleCount?: number; + negativePrompt?: string; + image?: { bytesBase64Encoded: string; mimeType: string }; + pollIntervalMs?: number; + maxWaitMs?: number; + } +): Promise<{ base64?: string; url?: string; format: string }> { + const auth = await resolveVertexAuth(credentials); + const submit = buildModelRequest(auth, options.model, "predictLongRunning"); + + const instance: Record = { prompt: options.prompt }; + if (options.image) instance.image = options.image; + const parameters: Record = { + sampleCount: typeof options.sampleCount === "number" ? options.sampleCount : 1, + }; + if (options.aspectRatio) parameters.aspectRatio = options.aspectRatio; + if (typeof options.durationSeconds === "number") parameters.durationSeconds = options.durationSeconds; + if (options.negativePrompt) parameters.negativePrompt = options.negativePrompt; + + const submitRes = await fetch(submit.url, { + method: "POST", + headers: submit.headers, + body: JSON.stringify({ instances: [instance], parameters }), + }); + if (!submitRes.ok) throw await vertexError(submitRes); + const op = await submitRes.json(); + const operationName = (op as { name?: unknown })?.name; + if (typeof operationName !== "string" || operationName.length === 0) { + throw new Error("Vertex Veo did not return an operation name"); + } + + const poll = buildModelRequest(auth, options.model, "fetchPredictOperation"); + const intervalMs = options.pollIntervalMs && options.pollIntervalMs > 0 ? options.pollIntervalMs : 10000; + const maxWaitMs = options.maxWaitMs && options.maxWaitMs > 0 ? options.maxWaitMs : 5 * 60 * 1000; + const deadline = Date.now() + maxWaitMs; + + while (Date.now() < deadline) { + await sleep(intervalMs); + const pollRes = await fetch(poll.url, { + method: "POST", + headers: poll.headers, + body: JSON.stringify({ operationName }), + }); + if (!pollRes.ok) throw await vertexError(pollRes); + const pollData = await pollRes.json(); + if ((pollData as { done?: unknown })?.done) { + const opError = (pollData as { error?: { message?: unknown } })?.error; + if (opError) throw new Error(String(opError.message || "Veo operation failed")); + const videos = (pollData as { response?: { videos?: unknown } })?.response?.videos; + const video = Array.isArray(videos) ? (videos[0] as Record) : null; + if (video && typeof video.bytesBase64Encoded === "string") { + return { base64: video.bytesBase64Encoded, format: "mp4" }; + } + if (video && typeof video.gcsUri === "string") { + return { url: video.gcsUri, format: "mp4" }; + } + throw new Error("Veo operation completed but returned no video"); + } + } + throw new Error("Vertex Veo video generation timed out"); +} diff --git a/open-sse/services/__tests__/claudeTlsClient.test.ts b/open-sse/services/__tests__/claudeTlsClient.test.ts index 7eb2479b1a..1b62673bff 100644 --- a/open-sse/services/__tests__/claudeTlsClient.test.ts +++ b/open-sse/services/__tests__/claudeTlsClient.test.ts @@ -9,6 +9,25 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +// Carrega o modulo de forma ESTATICA (mesma convenção dos testes irmãos +// chatgptTlsClient.test.ts / grokTlsClient.test.ts). +// +// Por que isso importa: `claudeTlsClient.ts` puxa `tlsClientBase.ts` -> +// `open-sse/utils/proxyFetch.ts`, cujo grafo de modulos custa ~5-12s para o +// Vite transformar dentro de um worker jsdom do Vitest. Cada `await +// import("../claudeTlsClient.ts")` feito DENTRO de um `it()` cobrava esse custo +// do orcamento do teste (testTimeout padrao = 5000ms), entao o PRIMEIRO teste do +// arquivo estourava por timeout sempre que a maquina estava sob carga — que e +// exatamente o caso quando a suite inteira roda com 20 workers em paralelo +// (`npm run test:vitest:ui`). Rodando o arquivo sozinho numa maquina ociosa ele +// passava por pouco, o que fazia a falha parecer poluicao entre arquivos. +// +// Com o import estatico o custo do grafo e pago na fase de COLETA do arquivo +// (que nao esta sujeita ao testTimeout) e os `await import()` de dentro dos +// testes passam a resolver do registro ja quente, em ~0ms. Nenhuma assercao foi +// alterada. +import "../claudeTlsClient.ts"; + describe("claudeTlsClient", () => { beforeEach(() => { // Clear env vars before each test @@ -273,15 +292,16 @@ describe("claudeTlsClient", () => { await tlsFetchClaude("https://claude.ai/test", {}); - // The testOverride is called with the raw options object BEFORE proxy - // resolution occurs (see claudeTlsClient.ts line 258: - // `if (testOverride) return testOverride(url, options)`). - // Proxy resolution (env var → proxyUrl) only runs inside the real - // tls-client path, which is bypassed when an override is active. - // So callOptions here is exactly the {} we passed — no proxyUrl injected. + // #10910 passou a resolver proxyUrl ANTES de chamar o testOverride + // (tlsClientBase.ts: "Resolve proxyUrl early so test overrides and the real + // path both see it"), justamente para que o override enxergue o mesmo proxy + // que o caminho real usaria. A assercao anterior travava o comportamento + // antigo — override recebia o {} cru — e contradizia o proprio nome deste + // teste, que diz verificar o fallback para a env var. Agora ela confere o + // fallback de fato. expect(mockFn).toHaveBeenCalledOnce(); const callOptions = mockFn.mock.calls[0][1]; - expect(callOptions.proxyUrl).toBeUndefined(); + expect(callOptions.proxyUrl).toBe("http://env-proxy:8080"); __setTlsFetchOverrideForTesting(null); delete process.env.HTTPS_PROXY; diff --git a/open-sse/services/contextHandoff.ts b/open-sse/services/contextHandoff.ts index 3a2f0d57a3..72ec1effa4 100644 --- a/open-sse/services/contextHandoff.ts +++ b/open-sse/services/contextHandoff.ts @@ -605,6 +605,69 @@ export function shouldGenerateUniversalHandoff(options: { return "generate"; } +// #11552 — universal-handoff regeneration backoff. +// +// A universal handoff whose summary comes back unparseable persists NOTHING, so +// `shouldGenerateUniversalHandoff` keeps answering "generate" and the very next +// model switch in the same session re-issues the same full-history +// summarization call and throws the answer away again. With a switch-heavy +// strategy (weighted, random, round-robin, p2c) the models alternate on almost +// every turn, so that becomes an extra discarded upstream call on a large +// fraction of requests — real money on a paid provider, real quota on a metered +// one. Back off per (session, combo) instead of hammering. +// +// Scope is deliberately narrow: only the "responded, but the content is not a +// usable handoff" outcome is tracked. A transient upstream failure +// (`!response.ok`) is NOT — that one is worth retrying on the next switch, and +// the context-relay path relies on exactly that behavior +// (tests/unit/context-handoff.test.ts → "allows a new attempt after a failed +// in-flight generation"). +const HANDOFF_UNPARSEABLE_BASE_COOLDOWN_MS = 5 * 60 * 1000; +const HANDOFF_UNPARSEABLE_MAX_COOLDOWN_MS = 60 * 60 * 1000; +const MAX_TRACKED_HANDOFF_COOLDOWNS = 500; + +type HandoffCooldownState = { consecutive: number; retryAfter: number }; +const universalHandoffCooldowns = new Map(); + +type UniversalHandoffOutcome = "generated" | "unparseable" | "unavailable"; + +function isUniversalHandoffCoolingDown(key: string): boolean { + const entry = universalHandoffCooldowns.get(key); + if (!entry) return false; + return Date.now() < entry.retryAfter; +} + +function pruneUniversalHandoffCooldowns(): void { + if (universalHandoffCooldowns.size <= MAX_TRACKED_HANDOFF_COOLDOWNS) return; + const now = Date.now(); + for (const [key, entry] of universalHandoffCooldowns) { + if (entry.retryAfter <= now) universalHandoffCooldowns.delete(key); + } + // Map iterates in insertion order, so this evicts the least recently touched + // keys first (every record re-inserts its key at the tail). + while (universalHandoffCooldowns.size > MAX_TRACKED_HANDOFF_COOLDOWNS) { + const oldest = universalHandoffCooldowns.keys().next(); + if (oldest.done) break; + universalHandoffCooldowns.delete(oldest.value); + } +} + +function recordUniversalHandoffUnparseable(key: string): void { + const consecutive = (universalHandoffCooldowns.get(key)?.consecutive ?? 0) + 1; + const cooldownMs = Math.min( + HANDOFF_UNPARSEABLE_BASE_COOLDOWN_MS * 2 ** (consecutive - 1), + HANDOFF_UNPARSEABLE_MAX_COOLDOWN_MS + ); + universalHandoffCooldowns.delete(key); + universalHandoffCooldowns.set(key, { consecutive, retryAfter: Date.now() + cooldownMs }); + pruneUniversalHandoffCooldowns(); +} + +/** Test seam: drop all universal-handoff regeneration cooldowns. */ +export function resetUniversalHandoffCooldowns(): void { + universalHandoffCooldowns.clear(); +} + /** * Generate a universal handoff summary for any model/provider switch. */ @@ -619,13 +682,13 @@ async function generateUniversalHandoffAsync(options: { maxMessages: number; providerAllowlist: string[]; handleSingleModel: (body: Record, modelStr: string) => Promise; -}): Promise { +}): Promise { const selectedMessages = selectMessagesForSummary( Array.isArray(options.messages) ? options.messages : [], options.maxMessages ); const historyText = formatMessagesForPrompt(selectedMessages); - if (!historyText) return; + if (!historyText) return "unavailable"; const summaryPrompt = HANDOFF_PROMPT_TEMPLATE.replace("{HISTORY}", historyText); const summaryModel = options.handoffModel || options.currModel; @@ -634,7 +697,9 @@ async function generateUniversalHandoffAsync(options: { const slashIdx = summaryModel.indexOf("/"); const modelProvider = slashIdx > 0 ? summaryModel.slice(0, slashIdx) : ""; if (modelProvider && !options.providerAllowlist.includes(modelProvider)) { - return; + // Policy skip, not a failure: nothing was generated and nothing was + // unparseable, so the #11552 backoff state must stay untouched. + return "unavailable"; } } @@ -649,7 +714,7 @@ async function generateUniversalHandoffAsync(options: { }; const response = await options.handleSingleModel(summaryBody, summaryModel); - if (!response.ok) return; + if (!response.ok) return "unavailable"; let content = ""; try { @@ -664,7 +729,7 @@ async function generateUniversalHandoffAsync(options: { } const parsed = parseHandoffJSON(content); - if (!parsed) return; + if (!parsed) return "unparseable"; upsertHandoff({ sessionId: options.sessionId, @@ -681,6 +746,7 @@ async function generateUniversalHandoffAsync(options: { generatedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + options.ttlMs).toISOString(), }); + return "generated"; } export function maybeGenerateUniversalHandoff(options: { @@ -704,6 +770,10 @@ export function maybeGenerateUniversalHandoff(options: { if (!options.sessionId) return; const inflightKey = getInflightKey(options.sessionId, options.comboName); + // #11552: the previous attempt for this session/combo answered with something + // that is not a usable handoff. Re-asking on every model switch just burns an + // upstream call whose response is discarded — wait out the backoff instead. + if (isUniversalHandoffCoolingDown(inflightKey)) return; if (inflightHandoffGenerations.has(inflightKey)) return; inflightHandoffGenerations.add(inflightKey); @@ -722,6 +792,10 @@ export function maybeGenerateUniversalHandoff(options: { providerAllowlist: options.universalConfig.providerAllowlist, handleSingleModel: options.handleSingleModel, }) + .then((outcome) => { + if (outcome === "unparseable") recordUniversalHandoffUnparseable(inflightKey); + else if (outcome === "generated") universalHandoffCooldowns.delete(inflightKey); + }) .catch((err) => { if (process.env.NODE_ENV !== "test") { console.warn("[universal-handoff] Generation failed:", err?.message || err); diff --git a/open-sse/services/inAppLoginService.ts b/open-sse/services/inAppLoginService.ts index 78174298db..7135b4e43f 100644 --- a/open-sse/services/inAppLoginService.ts +++ b/open-sse/services/inAppLoginService.ts @@ -18,6 +18,7 @@ import { TokenExtractionConfig, type TokenSource, } from "./tokenExtractionConfig"; +import { matchesCookieDomain } from "../utils/cookieDomain"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -196,9 +197,14 @@ export class InAppLoginService extends EventEmitter { for (const source of tokenSources) { if (source.type === "cookie") { const domain = source.domain || undefined; + // Exact host or dot-boundary suffix, never `includes()`: a cookie + // from `.attacker.tld` would otherwise be captured and + // persisted as the operator's credential. Same class CodeQL flagged + // in volcengineConsoleAutoLogin (#860/#861); this callsite was not + // flagged because the expected domain is config-supplied. const matched = cookies.find( (c: any) => - c.name === source.name && (!domain || c.domain.includes(domain.replace(/^\./, ""))) + c.name === source.name && (!domain || matchesCookieDomain(c.domain, domain)) ); if (matched && !credentials[source.name]) { credentials[source.name] = matched.value; diff --git a/open-sse/services/tokenExtractionConfig.ts b/open-sse/services/tokenExtractionConfig.ts index 3ae017cd35..2c02577b9d 100644 --- a/open-sse/services/tokenExtractionConfig.ts +++ b/open-sse/services/tokenExtractionConfig.ts @@ -173,6 +173,26 @@ const RAW_CONFIGS: TokenExtractionConfig[] = [ } ), + // ── Volcano Engine Ark Console ─────────────────────────── + config( + "volcengine-console", + "Volcano Engine Ark Console", + "https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan", + "https://console.volcengine.com", + [ + { type: "cookie", name: "digest", domain: ".volcengine.com" }, + { type: "cookie", name: "AccountID", domain: ".volcengine.com" }, + { type: "cookie", name: "csrfToken", domain: ".volcengine.com" }, + { type: "cookie", name: "userInfo", domain: ".volcengine.com" }, + ], + "Log in to the Volcano Engine Ark console. The console session is used to discover Agent/Coding Plan API keys and live quota usage.", + { + cookieDomain: ".volcengine.com", + successUrlPattern: /console\.volcengine\.com\/ark/i, + pollingConfig: { timeout: 300_000, minLoginTime: 3000 }, + } + ), + // ── Kimi Web ────────────────────────────────────────────── config( "kimi-web", diff --git a/open-sse/services/volcengineConsoleAutoLogin.ts b/open-sse/services/volcengineConsoleAutoLogin.ts index c0e8762144..0e3b681820 100644 --- a/open-sse/services/volcengineConsoleAutoLogin.ts +++ b/open-sse/services/volcengineConsoleAutoLogin.ts @@ -28,6 +28,7 @@ */ import { randomUUID } from "crypto"; +import { matchesCookieDomain } from "../utils/cookieDomain"; // ─── Public types ─────────────────────────────────────────────────────────── @@ -96,13 +97,6 @@ const ARK_CONSOLE_URL = /** Cookie names required for a valid console session (mirrors tokenExtractionConfig) */ const REQUIRED_COOKIES = ["digest", "AccountID", "csrfToken", "userInfo"] as const; -/** Exact-domain match for session cookies — substring checks would also accept - * look-alike hosts (e.g. `volcengine.com.evil.test`). Playwright may report the - * domain with or without a leading dot. */ -function isVolcengineCookieDomain(domain: string): boolean { - return domain === "volcengine.com" || domain.endsWith(".volcengine.com"); -} - const DEFAULT_SESSION_TIMEOUT = 300_000; const SUBMIT_COOKIE_TIMEOUT = 90_000; const CAPTURE_POLL_INTERVAL = 1_000; @@ -238,6 +232,21 @@ export function normalizePhone(raw: string): string | null { return /^1\d{10}$/.test(bare) ? bare : null; } +/** + * Whether a cookie's `domain` belongs to the Volcengine console. + * + * Cookie domains must be matched by exact host or dot-boundary suffix, never by + * substring: `domain.includes("volcengine.com")` also accepted + * `volcengine.com.attacker.tld` and `notvolcengine.com`, so a cookie named + * `digest`/`AccountID`/`csrfToken`/`userInfo` set by a look-alike host was + * harvested as an operator credential and persisted as a provider connection + * (CodeQL js/incomplete-url-substring-sanitization #860/#861). Mirrors + * `isAdobeCookieDomain` in adobeFireflyBrowserLogin.ts. + */ +export function isVolcengineCookieDomain(domain: string | undefined): boolean { + return matchesCookieDomain(domain, "volcengine.com"); +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/open-sse/utils/cookieDomain.ts b/open-sse/utils/cookieDomain.ts new file mode 100644 index 0000000000..af704b26a4 --- /dev/null +++ b/open-sse/utils/cookieDomain.ts @@ -0,0 +1,34 @@ +/** + * Cookie-domain matching for browser-driven credential capture. + * + * Every in-app / console login flow harvests cookies out of a Playwright + * context and persists them as operator credentials, so "is this cookie from + * the site I sent the browser to?" is an authorization decision. A substring + * test is not one: `domain.includes("example.com")` also accepts + * `example.com.attacker.tld` and `notexample.com`, which lets a look-alike host + * hand us cookies we then store as the operator's real credentials + * (CodeQL js/incomplete-url-substring-sanitization). + * + * A cookie domain is matched by exact host or dot-boundary suffix — nothing + * else. Leading dots (the RFC 6265 "domain-matches any subdomain" spelling) and + * case are normalized away on both sides. + */ +export function matchesCookieDomain( + cookieDomain: string | undefined, + expectedDomain: string | undefined +): boolean { + const expected = normalizeCookieDomain(expectedDomain); + if (!expected) return false; + + const actual = normalizeCookieDomain(cookieDomain); + if (!actual) return false; + + return actual === expected || actual.endsWith(`.${expected}`); +} + +function normalizeCookieDomain(domain: string | undefined): string { + return String(domain || "") + .trim() + .replace(/^\.+/, "") + .toLowerCase(); +} diff --git a/package-lock.json b/package-lock.json index 77455fee88..4214aef2e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26176,6 +26176,17 @@ "node": ">= 14" } }, + "node_modules/libxmljs2/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/libxmljs2/node_modules/cacache": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", diff --git a/package.json b/package.json index 2856cc65c8..08e5122feb 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\"", "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", "test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", - "test:unit:ci:shard": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD \"tests/unit/serial/**/*.test.ts\"", + "test:unit:ci:shard": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD \"tests/unit/serial/**/*.test.ts\"", "test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", "test:scoped": "bash scripts/quality/test-scoped.sh", "test:scoped:staged": "bash scripts/quality/test-scoped.sh --staged", @@ -260,7 +260,7 @@ "release:contributors": "node scripts/release/gen-contributors.mjs", "release:uncovered": "node scripts/release/list-uncovered-commits.mjs", "test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", - "test:unit:serial": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/unit/serial/**/*.test.ts\"", + "test:unit:serial": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/unit/serial/**/*.test.ts\"", "alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs" }, "dependencies": { diff --git a/skills/omni-inference/SKILL.md b/skills/omni-inference/SKILL.md index 6a85683888..1f33e692bc 100644 --- a/skills/omni-inference/SKILL.md +++ b/skills/omni-inference/SKILL.md @@ -390,6 +390,54 @@ curl -X POST https://localhost:20128/api/v1/audio/translations \ -d '{}' ``` +### GET /api/v1/voices + +List ElevenLabs voices + +Proxies `GET https://api.elevenlabs.io/v1/voices` using the stored `elevenlabs` provider credentials (the caller never sends `xi-api-key`). The incoming query string is forwarded unchanged. + +```bash +curl https://localhost:20128/api/v1/voices \ + -H "Authorization: Bearer $OMNIROUTE_TOKEN" +``` + +### POST /api/v1/speech-to-text + +ElevenLabs speech-to-text + +Streams the request body to `POST https://api.elevenlabs.io/v1/speech-to-text` using the stored `elevenlabs` provider credentials. `content-type` and `accept` are forwarded; the upstream body is relayed unchanged. + +```bash +curl -X POST https://localhost:20128/api/v1/speech-to-text \ + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +### POST /api/v1/text-to-speech/{voiceId} + +ElevenLabs text-to-speech + +Streams the request body to `POST https://api.elevenlabs.io/v1/text-to-speech/{voiceId}` using the stored `elevenlabs` provider credentials. `voiceId` must match `^[A-Za-z0-9_-]+$` or the request is rejected with 400 before any upstream call. + +```bash +curl -X POST https://localhost:20128/api/v1/text-to-speech/{voiceId} \ + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +### GET /api/v1/explain/routing + +Routing explainability snapshot + +Returns the most recent routing events (bounded in-memory ring buffer) plus the per-provider/model quality snapshot from `open-sse/services/routing`. Routing metadata only — never prompts, bodies, headers or credentials. Auth mirrors `/api/v1/combos`: a valid Bearer API key or a dashboard session; with `REQUIRE_API_KEY=false` anonymous reads are allowed. + +```bash +curl https://localhost:20128/api/v1/explain/routing \ + -H "Authorization: Bearer $OMNIROUTE_TOKEN" +``` + ### GET /api/v1/providers/suggested-models Suggested media models diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index ec2c0836b7..e4c8818a57 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -3345,6 +3345,51 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo )} + {isExpertMode && ( +
+ +
+ { + setManualModelInput(e.target.value); + setManualModelError(""); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddManualModel(); + } + }} + placeholder="provider/model" + data-testid="combo-manual-model-input" + className="flex-1 text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 text-text-main focus:border-primary focus:outline-none font-mono" + /> + +
+ {(manualModelError || manualModelHasDuplicate) && ( +
+ {manualModelError || + getI18nOrFallback( + t, + "builderDuplicateExact", + "This exact provider/model/account step is already in the combo." + )} +
+ )} +
+ )} + ({ useTranslations: () => (k: string) => k })); vi.mock("@/shared/components/ProviderTestSlideOver", () => ({ default: () => null })); vi.mock("@/shared/components/ProviderIcon", () => ({ default: () => null })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: () => {} }) })); describe("ProviderCard — #6936 audio-transcriptions provider badge", () => { let container: HTMLDivElement | null = null; diff --git a/src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts b/src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts index c3d0427400..cd58d45e62 100644 --- a/src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts +++ b/src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts @@ -1,15 +1,9 @@ import { NextResponse } from "next/server"; -import { z } from "zod"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; - -const volcengineCodeSchema = z.object({ - code: z.union([z.string(), z.number()]).optional(), - captcha: z.string().optional(), - timeout: z.number().int().positive().max(600_000).optional(), -}); +import { formatValidationMessage, validateBody } from "@/shared/validation/helpers"; +import { volcenginePlanCodeSchema } from "@/shared/validation/schemas/volcenginePlan"; /** * POST /api/providers/volcengine-plan/connect/[sessionId]/code @@ -25,19 +19,23 @@ export async function POST( if (auth) return auth; const { sessionId } = await params; - const rawBody = await request.json().catch(() => ({})); - const validation = validateBody(volcengineCodeSchema, rawBody); - if (isValidationFailure(validation)) { + const raw = await request.json().catch(() => ({})); + // Validate BEFORE the session lookup: a malformed body is the caller's bug + // regardless of whether the session happens to exist, and answering 404 for + // it (the previous behavior) hides the real cause. + const validation = validateBody(volcenginePlanCodeSchema, raw); + if (!validation.success) { return NextResponse.json( - { success: false, error: validation.error.message, details: validation.error.details }, + { success: false, error: formatValidationMessage(validation.error) }, { status: 400 } ); } - const body = validation.data; + const { code, captcha, timeout } = validation.data; try { - const { volcengineConsoleAutoLoginService } = - await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts"); + const { volcengineConsoleAutoLoginService } = await import( + "@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts" + ); if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) { return NextResponse.json( @@ -46,13 +44,9 @@ export async function POST( ); } - const timeout = body.timeout; - const session = await volcengineConsoleAutoLoginService.submitCode( - sessionId, - body.code != null ? String(body.code) : "", - body.captcha, - { timeout } - ); + const session = await volcengineConsoleAutoLoginService.submitCode(sessionId, code, captcha, { + timeout, + }); if (!session) { return NextResponse.json( { success: false, error: "Unknown or expired Volcano login session" }, diff --git a/src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts b/src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts index 32b7e6281c..c6704d2ff3 100644 --- a/src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts +++ b/src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts @@ -1,14 +1,9 @@ import { NextResponse } from "next/server"; -import { z } from "zod"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; - -const volcengineIdentitySchema = z.object({ - index: z.number().int().min(0), - timeout: z.number().int().positive().max(600_000).optional(), -}); +import { formatValidationMessage, validateBody } from "@/shared/validation/helpers"; +import { volcenginePlanIdentitySchema } from "@/shared/validation/schemas/volcenginePlan"; /** * POST /api/providers/volcengine-plan/connect/[sessionId]/identity @@ -23,19 +18,21 @@ export async function POST( if (auth) return auth; const { sessionId } = await params; - const rawBody = await request.json().catch(() => ({})); - const validation = validateBody(volcengineIdentitySchema, rawBody); - if (isValidationFailure(validation)) { + const raw = await request.json().catch(() => ({})); + // Validate BEFORE the session lookup — see the sibling code/route.ts note. + const validation = validateBody(volcenginePlanIdentitySchema, raw); + if (!validation.success) { return NextResponse.json( - { success: false, error: validation.error.message, details: validation.error.details }, + { success: false, error: formatValidationMessage(validation.error) }, { status: 400 } ); } - const body = validation.data; + const { index, timeout } = validation.data; try { - const { volcengineConsoleAutoLoginService } = - await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts"); + const { volcengineConsoleAutoLoginService } = await import( + "@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts" + ); if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) { return NextResponse.json( @@ -44,8 +41,7 @@ export async function POST( ); } - const timeout = body.timeout; - const session = await volcengineConsoleAutoLoginService.selectIdentity(sessionId, body.index, { + const session = await volcengineConsoleAutoLoginService.selectIdentity(sessionId, index, { timeout, }); if (!session) { diff --git a/src/app/api/providers/volcengine-plan/connect/route.ts b/src/app/api/providers/volcengine-plan/connect/route.ts index 28151bb047..d6bebe20a3 100644 --- a/src/app/api/providers/volcengine-plan/connect/route.ts +++ b/src/app/api/providers/volcengine-plan/connect/route.ts @@ -1,36 +1,31 @@ import { NextResponse } from "next/server"; -import { z } from "zod"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; - -const volcengineConnectSchema = z.object({ - phone: z.string().trim().min(1).optional(), - timeout: z.number().int().positive().max(600_000).optional(), -}); +import { formatValidationMessage, validateBody } from "@/shared/validation/helpers"; +import { volcenginePlanConnectSchema } from "@/shared/validation/schemas/volcenginePlan"; export async function POST(request: Request): Promise { const auth = await requireManagementAuth(request); if (auth) return auth; - const rawBody = await request.json().catch(() => ({})); - const validation = validateBody(volcengineConnectSchema, rawBody); - if (isValidationFailure(validation)) { + const raw = await request.json().catch(() => ({})); + const validation = validateBody(volcenginePlanConnectSchema, raw); + if (!validation.success) { return NextResponse.json( - { success: false, error: validation.error.message, details: validation.error.details }, + { success: false, error: formatValidationMessage(validation.error) }, { status: 400 } ); } - const body = validation.data; - const timeout = body.timeout; + const { phone, timeout } = validation.data; // Auto flow: phone present → start a session-based headless phone/SMS login. - if (body.phone) { + if (phone) { try { - const { volcengineConsoleAutoLoginService } = - await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts"); - const started = await volcengineConsoleAutoLoginService.startLogin(body.phone, { timeout }); + const { volcengineConsoleAutoLoginService } = await import( + "@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts" + ); + const started = await volcengineConsoleAutoLoginService.startLogin(phone, { timeout }); if (!started.ok) { return NextResponse.json({ success: false, error: started.error }, { status: 400 }); } diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 42ef23e931..f79c14a05b 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -151,7 +151,19 @@ export { __flushCatalogBackgroundRefreshForTest, __forceCatalogInFlightRejectionForTest, } from "./catalogCache"; -export type { CachedCatalog } from "./catalogCache"; +export type { CachedCatalog, BackgroundRefreshScheduler } from "./catalogCache"; + +/** + * Per-call options for {@link getUnifiedModelsResponse}. + * + * Restored in #11551: `/v1/models` passes Next's `after()` so the stale-while- + * revalidate rebuild is deferred until after the response flush. #9199 had removed + * the injection point while the route kept passing it, so the argument was silently + * dropped and the refresh ran on a plain `setTimeout`. + */ +export type CatalogResponseOptions = { + scheduleBackgroundRefresh?: BackgroundRefreshScheduler; +}; const BUILTIN_AUTO_YIELD_INTERVAL = 2; diff --git a/src/app/api/v1/models/catalogCache.ts b/src/app/api/v1/models/catalogCache.ts index 79ef366f1b..c5a9509397 100644 --- a/src/app/api/v1/models/catalogCache.ts +++ b/src/app/api/v1/models/catalogCache.ts @@ -80,7 +80,7 @@ export type BackgroundRefreshScheduler = (task: () => Promise) => void; * so fall back to the macrotask there. Those callers have no response being flushed, so * the deferral is all they ever needed. */ -function defaultBackgroundRefreshScheduler(task: () => Promise): void { +export function defaultBackgroundRefreshScheduler(task: () => Promise): void { try { after(task); } catch { @@ -135,6 +135,31 @@ export type CatalogCacheOptions = { */ export const CATALOG_CACHE_TTL_MS_DEFAULT = 60_000; +/** + * Per-call knobs for {@link resolveCachedCatalogResponse}. + * + * `hideAutoCombos` / `hideNoThinkVariants` are catalog-shape dimensions folded into + * the cache key. `getStaleWhileRevalidateMs` and `scheduleBackgroundRefresh` are the + * injection points restored in #11551: the route wires Next's `after()` so the + * background refresh runs only once the response has been flushed to the client. + */ + +/** Defers `task` until it is safe to run without delaying the current response. */ + +/** + * Default scheduler (#8728 / #11551). + * + * Next's `after()` runs the task once the response has been flushed, which is the + * whole point of the stale-while-revalidate path: the builder is overwhelmingly + * synchronous under the single-threaded App Router, so running it before the flush + * pins the event loop and the "served immediately" stale body only reaches the + * client after the rebuild finishes. + * + * `after()` requires a Next request scope. Callers outside one (instrumentation + * warm-up, direct unit-test imports) fall back to a macrotask, which preserves the + * "hand the response back first" ordering within the same process. + */ + type CatalogInFlight = { version: number; promise: Promise; @@ -155,10 +180,7 @@ const catalogInFlight = new Map(); let _catalogBuilderRuns = 0; -function buildCatalogCacheKey( - request: Request, - catalogSettings?: { hideAutoCombos?: boolean; hideNoThinkVariants?: boolean } -): string { +function buildCatalogCacheKey(request: Request, catalogSettings?: CatalogCacheOptions): string { const url = new URL(request.url); const prefix = url.searchParams.get("prefix") || ""; const apiKey = extractApiKey(request) || ""; @@ -262,7 +284,9 @@ function startBackgroundRefresh( const refreshPromise: Promise = new Promise((resolve, reject) => { schedule(() => runBuilder(buildPayload, request) - .then((payload) => resolve(storePayload(cacheKey, payload, generation))) + .then((payload) => { + resolve(storePayload(cacheKey, payload, generation)); + }) .catch((err) => { console.error( `[catalog] Background stale-while-revalidate refresh failed for key "${cacheKey}":`, diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index e00f983164..f514c31af2 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -1267,7 +1267,7 @@ "agentBridge": "Agent Bridge", "agentBridgeSubtitle": "Interceptar tráfego de agentes IDE", "trafficInspector": "Inspector de Tráfego", - "trafficInspectorSubtitle": "__MISSING__:Inspect request and response traffic from your apps", + "trafficInspectorSubtitle": "Inspecione o tráfego de requisições e respostas dos seus apps", "trafficInspectorPurpose": "Veja exatamente o que sua aplicação envia e recebe dos provedores de IA. Funciona com qualquer cliente compatível com OpenAI.", "cliCode": "CLI Code's", "cliCodeSubtitle": "Ferramentas de código que apontam para o OmniRoute", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index fd140e4122..f96e87fe58 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1266,7 +1266,7 @@ "agentBridge": "Agent Bridge", "agentBridgeSubtitle": "Intercetar tráfego do agente do IDE", "trafficInspector": "Inspetor de Tráfego", - "trafficInspectorSubtitle": "__MISSING__:Inspect request and response traffic from your apps", + "trafficInspectorSubtitle": "Inspecione o tráfego de pedidos e respostas das suas aplicações", "cliCode": "Código da CLI", "cliCodeSubtitle": "Ferramentas de código a apontar para o OmniRoute", "cliAgents": "Agentes de CLI", diff --git a/src/lib/providerModels/geminiModelsParser.ts b/src/lib/providerModels/geminiModelsParser.ts index 9d4aa68de8..250dc58a53 100644 --- a/src/lib/providerModels/geminiModelsParser.ts +++ b/src/lib/providerModels/geminiModelsParser.ts @@ -27,6 +27,7 @@ const METHOD_TO_ENDPOINT: Record = { embedContent: "embeddings", predict: "images", predictLongRunning: "videos", + bidiGenerateContent: "audio", generateAnswer: "chat", }; diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 38e97aa0f2..15ea9c845d 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -13,7 +13,12 @@ import { } from "@/lib/db/providerLimits"; import { syncToCloud } from "@/lib/cloudSync"; import { setQuotaCache } from "@/domain/quotaCache"; -import { buildClaudeExtraUsageConnectionUpdate } from "@/lib/providers/claudeExtraUsage"; +import { + buildClaudeExtraUsageConnectionUpdate, + CLAUDE_EXTRA_USAGE_ERROR_SOURCE, + isClaudeExtraUsageBlockEnabled, + isClaudeExtraUsageQueued, +} from "@/lib/providers/claudeExtraUsage"; import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation"; import { clearRecoveredProviderState } from "@/sse/services/auth"; import { getMachineId } from "@/shared/utils/machine"; @@ -512,6 +517,82 @@ export function shouldClearErrorStateOnValidProbe( return probeValid && !hasActiveCooldown(connection, now); } +/** + * May an active cooldown be released because the REAL quota windows recovered? + * + * Only the synthetic-cooldown case (#10534) qualifies: lastErrorType + * "quota_exhausted" plus every governing window past its real reset with quota + * left. A window that is still exhausted — or whose reset is unknown/unparseable + * — keeps the connection locked, matching the kimi-coding partial-refresh + * semantics. + */ +function windowStillExhaustedAfterRealReset(value: unknown, nowMs: number): boolean { + if (!isRecord(value)) return false; + if (value.unlimited === true) return false; + const remaining = + typeof value.remaining === "number" + ? value.remaining + : typeof value.remainingPercentage === "number" + ? value.remainingPercentage + : null; + if (remaining !== null && remaining > 0) return false; + if (value.resetAt == null) return true; + const resetMs = Date.parse(String(value.resetAt)); + if (Number.isNaN(resetMs)) return true; + return resetMs > nowMs; +} + +function isQuotaExhaustedCooldownReleasable( + connection: Pick< + ProviderConnectionLike, + "lastErrorType" | "lastErrorSource" | "provider" | "providerSpecificData" + >, + usage: JsonRecord +): boolean { + if (connection.lastErrorType !== "quota_exhausted") return false; + // An extra-usage block is a POLICY lock, not a quota window: the session and + // weekly windows genuinely look recovered in the very same fetch, so the + // window scan below would happily release it. It stays locked while the + // policy is on and upstream still reports extra usage queued. + if ( + connection.lastErrorSource === CLAUDE_EXTRA_USAGE_ERROR_SOURCE && + isClaudeExtraUsageBlockEnabled(connection.provider, connection.providerSpecificData) && + isClaudeExtraUsageQueued(usage) + ) { + return false; + } + const quotas = usage?.quotas; + if (!isRecord(quotas)) return false; + const values = Object.values(quotas); + if (values.length === 0) return false; + const nowMs = Date.now(); + return !values.some((value) => windowStillExhaustedAfterRealReset(value, nowMs)); +} + +/** + * Is an explicit cooldown still in the future? + * + * A rateLimitedUntil set by the upstream 429 handler is a hard statement and + * must never be overruled by a quota poll. + * + * Gate on the timestamp alone; lastErrorType stays irrelevant here. + */ + +/** + * Whether a connection test may wipe the persisted error/cooldown state. + * + * A successful probe proves the CREDENTIAL is valid; it does not prove an + * exhausted quota window reopened — the probe is a cheap auth/models call that + * never touches the chat quota a weekly cap applies to. The credential-health + * scheduler runs that probe against every connection every 300s, so without this + * gate a weekly-capped connection was reset to `active` / `rateLimitedUntil=null` + * within 30s of every restart and dispatched straight back into the same 429. + * + * Same rule as `maybeClearRecoveredQuotaState`: a future `rateLimitedUntil` is + * the 429 handler's hard statement and no poller may overrule it. Once the + * window elapses, the next probe clears the state normally. + */ + export async function maybeClearRecoveredQuotaState( connection: ProviderConnectionLike, usage: JsonRecord @@ -519,17 +600,17 @@ export async function maybeClearRecoveredQuotaState( if (!hasUsableQuota(usage)) return connection; if (isTerminalStatusForQuotaRecovery(connection.testStatus)) return connection; if (hasActiveCooldown(connection)) { - // A future rateLimitedUntil written from a real upstream signal is a hard - // statement no poller may overrule (#11277) — executor-sourced rate limits - // and extra-usage policy blocks included. Only a SYNTHETIC cooldown (a - // quota_exhausted lock persisted without an upstream reset, e.g. the - // Claude-subscription poller's 1h lockout) yields to positive live-window - // evidence that the real quota has already replenished past its reset. - const syntheticRecoveryOverride = - connection.lastErrorType === "quota_exhausted" && - connection.lastErrorSource !== "extra_usage" && - syntheticCooldownOutlivedByRealWindows(usage); - if (!syntheticRecoveryOverride) return connection; + // #11355 made an active rateLimitedUntil an unconditional stop, which is right + // for an upstream-derived cooldown but over-broad for the one case #10534 was + // built for: a Claude-subscription 429 persists a SYNTHETIC 1h cooldown because + // the upstream sent no parseable reset. When the later poll shows every window + // that governs this connection has really reset WITH quota available, holding + // that synthetic cooldown just deadlocks the connection for an hour. + // + // Narrow by design: only lastErrorType "quota_exhausted" (the synthetic-cooldown + // writer) is eligible, and a single still-exhausted or unknown-reset window keeps + // the lock. Every other reason keeps #11355/#11277 semantics untouched. + if (!isQuotaExhaustedCooldownReleasable(connection, usage)) return connection; } const hasTransientState = diff --git a/src/server/authz/peerContext.ts b/src/server/authz/peerContext.ts new file mode 100644 index 0000000000..35fb1e0eb5 --- /dev/null +++ b/src/server/authz/peerContext.ts @@ -0,0 +1,87 @@ +import { timingSafeEqual } from "node:crypto"; + +import { getLegacyCliTokenSync, getMachineTokenSync } from "../../lib/machineToken"; +import type { PolicyContext } from "./context"; +import { CLI_TOKEN_HEADER, PEER_IP_HEADER, VIA_PROXY_HEADER } from "./headers"; +import { resolveStampedPeer, resolveStampedViaProxy } from "./peerStamp"; +import { isLoopbackHost, isPrivateLanHost } from "./routeGuard"; + +/** + * Peer-locality + local-CLI-token helpers shared by the route policies. + * + * Extracted from `policies/management.ts` because the PUBLIC policy + * needs the very same CLI-token verdict: `runAuthzPipeline` strips + * CLI_TOKEN_HEADER from the forwarded headers for EVERY route class, so a route + * handler can only learn that a local CLI authenticated from the subject stamp + * the policy produced. Without this, a PUBLIC-classified route that still calls + * `requireManagementAuth()` (e.g. GET /api/monitoring/health) can never see the + * local CLI as a management principal. + */ + +export function requestPeerAddress(ctx: PolicyContext): string | null { + // The Next proxy runtime exposes no socket/.ip, so the only trustworthy + // locality signal is the token-stamped PEER_IP_HEADER our custom server writes + // from the real TCP peer (scripts/dev/peer-stamp.mjs). We NEVER read the Host + // header here — it is client-controlled and spoofable. Absent/forged stamp → + // null → isLoopbackRequest/isPrivateLanRequest return false → fail closed. + const stamped = resolveStampedPeer( + ctx.request.headers?.get?.(PEER_IP_HEADER) ?? null, + process.env.OMNIROUTE_PEER_STAMP_TOKEN + ); + if (stamped) return stamped; + // Non-proxy callers (tests / direct Node) may carry a real socket peer. + return ctx.request.ip ?? ctx.request.socket?.remoteAddress ?? null; +} + +/** + * True when the inbound TCP request carried forwarding headers + * (`x-forwarded-for` / `x-real-ip`), as stamped by the custom Node server. When + * set, the socket peer is the reverse-proxy hop, not the end-user — so a + * loopback / private-LAN socket must NOT be trusted as local (Hard Rules #15 + + * #17, port of decolua/9router da667836). Token-validated; an attacker who + * knows the header name but not the per-process token cannot influence it. + */ +export function isViaProxyRequest(ctx: PolicyContext): boolean { + return resolveStampedViaProxy( + ctx.request.headers?.get?.(VIA_PROXY_HEADER) ?? null, + process.env.OMNIROUTE_PEER_STAMP_TOKEN + ); +} + +export function isLoopbackRequest(ctx: PolicyContext): boolean { + if (isViaProxyRequest(ctx)) return false; + const peerAddress = requestPeerAddress(ctx); + return peerAddress ? isLoopbackHost(peerAddress) : false; +} + +// Owner-authorized (2026-05-30): allow LOCAL_ONLY *paths* from a trusted private +// LAN, based on the real socket peer IP (not spoofable). Does NOT relax the +// CLI-token gate, which stays strictly loopback. Also falls back to "not LAN" +// when a reverse-proxy hop is detected (the apparent LAN IP would be the proxy, +// not the end-user — see isViaProxyRequest above). +export function isPrivateLanRequest(ctx: PolicyContext): boolean { + if (isViaProxyRequest(ctx)) return false; + const peerAddress = requestPeerAddress(ctx); + return peerAddress ? isPrivateLanHost(peerAddress) : false; +} + +/** Strictly-loopback machine-token check (constant-time). */ +export function hasValidLoopbackCliToken(ctx: PolicyContext): boolean { + if (process.env.OMNIROUTE_DISABLE_CLI_TOKEN === "true") return false; + if (!isLoopbackRequest(ctx)) return false; + const headers = ctx.request.headers; + const provided = headers.get(CLI_TOKEN_HEADER); + if (!provided) return false; + const expectedTokens = [getMachineTokenSync(), getLegacyCliTokenSync()].filter(Boolean); + return expectedTokens.some((expected) => { + if (provided.length !== expected.length) return false; + return timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); + }); +} + +/** The subject a validated local CLI request is stamped with. */ +export const LOCAL_CLI_SUBJECT = Object.freeze({ + kind: "management_key" as const, + id: "cli", + label: "local-cli-token", +}); diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index 0ab352b84a..38613f2304 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -1,7 +1,6 @@ import { createHash, timingSafeEqual } from "node:crypto"; import { isModelSyncInternalRequest } from "../../../shared/services/modelSyncScheduler"; import { isAuthRequired, isDashboardSessionAuthenticated } from "../../../shared/utils/apiAuth"; -import { getLegacyCliTokenSync, getMachineTokenSync } from "../../../lib/machineToken"; import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context"; import { allow, reject } from "../context"; import { extractApiKey, isValidApiKey } from "../../../sse/services/auth"; @@ -18,78 +17,20 @@ import { VIDEO_BRIDGE_DRILLDOWN_PATH, isVideoBridgeBrokerTokenRequest, } from "../../../lib/guardrails/videoBridgeBrokerAuth"; -import { CLI_TOKEN_HEADER, PEER_IP_HEADER, VIA_PROXY_HEADER } from "../headers"; -import { resolveStampedPeer, resolveStampedViaProxy } from "../peerStamp"; +import { + hasValidLoopbackCliToken, + isLoopbackRequest, + isPrivateLanRequest, + LOCAL_CLI_SUBJECT, +} from "../peerContext"; import { isAlwaysProtectedPath, isLocalOnlyBypassableByManageScope, isLocalOnlyPath, - isLoopbackHost, - isPrivateLanHost, } from "../routeGuard"; const MODEL_SYNC_MANAGEMENT_PATH = /^\/api\/providers\/[^/]+\/(sync-models|models)$/; -function requestPeerAddress(ctx: PolicyContext): string | null { - // The Next middleware runtime exposes no socket/.ip, so the only trustworthy - // locality signal is the token-stamped PEER_IP_HEADER our custom server writes - // from the real TCP peer (scripts/dev/peer-stamp.mjs). We NEVER read the Host - // header here — it is client-controlled and spoofable. Absent/forged stamp → - // null → isLoopbackRequest/isPrivateLanRequest return false → fail closed. - const stamped = resolveStampedPeer( - ctx.request.headers?.get?.(PEER_IP_HEADER) ?? null, - process.env.OMNIROUTE_PEER_STAMP_TOKEN - ); - if (stamped) return stamped; - // Non-middleware callers (tests / direct Node) may carry a real socket peer. - return ctx.request.ip ?? ctx.request.socket?.remoteAddress ?? null; -} - -/** - * True when the inbound TCP request carried forwarding headers - * (`x-forwarded-for` / `x-real-ip`), as stamped by the custom Node server. When - * set, the socket peer is the reverse-proxy hop, not the end-user — so a - * loopback / private-LAN socket must NOT be trusted as local (Hard Rules #15 + - * #17, port of decolua/9router da667836). Token-validated; an attacker who - * knows the header name but not the per-process token cannot influence it. - */ -function isViaProxyRequest(ctx: PolicyContext): boolean { - return resolveStampedViaProxy( - ctx.request.headers?.get?.(VIA_PROXY_HEADER) ?? null, - process.env.OMNIROUTE_PEER_STAMP_TOKEN - ); -} - -function isLoopbackRequest(ctx: PolicyContext): boolean { - if (isViaProxyRequest(ctx)) return false; - const peerAddress = requestPeerAddress(ctx); - return peerAddress ? isLoopbackHost(peerAddress) : false; -} - -// Owner-authorized (2026-05-30): allow LOCAL_ONLY *paths* from a trusted private -// LAN, based on the real socket peer IP (not spoofable). Does NOT relax the -// CLI-token gate, which stays strictly loopback. Also falls back to "not LAN" -// when a reverse-proxy hop is detected (the apparent LAN IP would be the proxy, -// not the end-user — see isViaProxyRequest above). -function isPrivateLanRequest(ctx: PolicyContext): boolean { - if (isViaProxyRequest(ctx)) return false; - const peerAddress = requestPeerAddress(ctx); - return peerAddress ? isPrivateLanHost(peerAddress) : false; -} - -function hasValidCliToken(ctx: PolicyContext): boolean { - if (process.env.OMNIROUTE_DISABLE_CLI_TOKEN === "true") return false; - if (!isLoopbackRequest(ctx)) return false; - const headers = ctx.request.headers; - const provided = headers.get(CLI_TOKEN_HEADER); - if (!provided) return false; - const expectedTokens = [getMachineTokenSync(), getLegacyCliTokenSync()].filter(Boolean); - return expectedTokens.some((expected) => { - if (provided.length !== expected.length) return false; - return timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); - }); -} - function hasBearerToken(headers: Headers): boolean { const authHeader = headers.get("authorization") ?? headers.get("Authorization"); return typeof authHeader === "string" && authHeader.trim().toLowerCase().startsWith("bearer "); @@ -272,8 +213,8 @@ export const managementPolicy: RoutePolicy = { }); } - if (hasValidCliToken(ctx)) { - return allow({ kind: "management_key", id: "cli", label: "local-cli-token" }); + if (hasValidLoopbackCliToken(ctx)) { + return allow({ ...LOCAL_CLI_SUBJECT }); } // MCP path carve-out (#9159): accept mcp:connect, manage, or admin diff --git a/src/server/authz/policies/public.ts b/src/server/authz/policies/public.ts index 588eebc9d6..e548a8dd81 100644 --- a/src/server/authz/policies/public.ts +++ b/src/server/authz/policies/public.ts @@ -1,9 +1,25 @@ import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context"; import { allow } from "../context"; +import { hasValidLoopbackCliToken, LOCAL_CLI_SUBJECT } from "../peerContext"; export const publicPolicy: RoutePolicy = { routeClass: "PUBLIC", - async evaluate(_ctx: PolicyContext): Promise { + async evaluate(ctx: PolicyContext): Promise { + // PUBLIC never rejects — but the SUBJECT still matters. `runAuthzPipeline` + // strips CLI_TOKEN_HEADER from the forwarded headers for every route class, + // so a handler can only learn that a local CLI authenticated from the stamp + // this policy produces. Some PUBLIC-classified routes intentionally serve a + // reduced anonymous view and the full one to a management principal (GET + // /api/monitoring/health, GHSA-mvf8-qc78-5mxm): without this branch the + // loopback CLI holding a valid machine token was permanently downgraded to + // the anonymous view: the health payload lost `version`, which is what the + // check:pack-boot release gate asserts on the packed tarball. Regression + // introduced by #11040 (GHSA-mvf8-qc78-5mxm hardening). + // The verdict is strictly loopback + constant-time token comparison, the + // same gate the MANAGEMENT policy applies. + if (hasValidLoopbackCliToken(ctx)) { + return allow({ ...LOCAL_CLI_SUBJECT }); + } return allow({ kind: "anonymous", id: "anonymous" }); }, }; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 0347a5f4f1..001ac195e5 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -75,6 +75,7 @@ export function getProviderConnectionFamilyIds(providerId: unknown): readonly st // Web / Cookie Providers + // API Key Providers // Sub-categories within APIKEY_PROVIDERS (used by dashboard and catalog views). @@ -144,6 +145,7 @@ export const AGGREGATOR_PROVIDER_IDS = new Set([ "helixmind", "tabitoken", "logfare", + ]); export const ENTERPRISE_CLOUD_PROVIDER_IDS = new Set([ diff --git a/src/shared/utils/containerEnv.ts b/src/shared/utils/containerEnv.ts index 00b81a5313..506ed6fd02 100644 --- a/src/shared/utils/containerEnv.ts +++ b/src/shared/utils/containerEnv.ts @@ -79,6 +79,52 @@ function stripTrailingSlash(p: string): string { return p.length > 1 && p.endsWith("/") ? p.replace(/\/+$/, "") : p; } +/** + * Filesystems that live in RAM or expose kernel state. A mount of one of these + * is never a bind mount from the host: a path under `--tmpfs /tmp`, or a + * container whose home sits on tmpfs, loses the file even before the container + * is recreated -- exactly the throwaway write this module exists to refuse. + * Every other type (ext4, xfs, btrfs, zfs, nfs, virtiofs, fuse.*, ...) can + * carry host data, so it still counts as proof the operator wired the path in. + */ +const NON_HOST_FS_TYPES = new Set([ + "autofs", + "binfmt_misc", + "bpf", + "cgroup", + "cgroup2", + "configfs", + "debugfs", + "devpts", + "devtmpfs", + "efivarfs", + "fusectl", + "hugetlbfs", + "mqueue", + "nsfs", + "proc", + "pstore", + "ramfs", + "rpc_pipefs", + "securityfs", + "selinuxfs", + "sysfs", + "tmpfs", + "tracefs", +]); + +/** + * mountinfo puts a variable number of optional fields after field 7 and closes + * them with a lone "-"; the field right after that separator is the filesystem + * type. Returns null when the line carries no separator, which the caller + * treats as "not proof of a host mount". + */ +function mountFsType(fields: string[]): string | null { + const separator = fields.indexOf("-", 6); + if (separator === -1) return null; + return fields[separator + 1] || null; +} + /** * True when `targetPath` is connected to a mount, in any of three ways: * @@ -88,6 +134,10 @@ function stripTrailingSlash(p: string): string { * the actual mounts — this is exactly how the compose `host` profile is * wired, so case 3 is not optional) * + * Only mounts backed by a filesystem that can hold host data count (see + * NON_HOST_FS_TYPES): a tmpfs/ramfs mount is throwaway storage, not a bind + * mount, so it must not clear the ephemeral flag. + * * Returns false whenever `/proc/self/mountinfo` is unavailable, which keeps * host machines (macOS, Windows) on the conservative path. */ @@ -111,6 +161,10 @@ export function hasBindMountAt( if (fields.length < 5) continue; const mountPoint = stripTrailingSlash(decodeMountPath(fields[4] || "")); if (!mountPoint || mountPoint === "/") continue; + // An in-memory/pseudo filesystem does not reach the host, so it can never + // stand in for the bind mount the operator was asked to wire up. + const fsType = mountFsType(fields); + if (!fsType || NON_HOST_FS_TYPES.has(fsType)) continue; if (mountPoint === target) return true; if (mountPoint.startsWith(`${target}/`)) return true; diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index 004cdd5aa0..81f539d630 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -377,6 +377,17 @@ export const createProviderNodeSchema = z message: "Prefix is required", path: ["prefix"], }); + } else if (isReservedProviderPrefix(value.prefix.trim())) { + // Reserved-prefix guard (tokenrouter bug): the runtime model resolver skips + // compatible-node lookup for built-in registry ids/aliases, so a node + // created with such a prefix could never be reached by it and silently + // routed requests to the built-in provider instead. Reject at the write + // path. Case-sensitive to match the runtime guard exactly. + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: reservedProviderPrefixMessage(value.prefix.trim()), + path: ["prefix"], + }); } if (nodeType === "openai-compatible" && !value.apiType) { ctx.addIssue({ diff --git a/src/shared/validation/schemas/volcenginePlan.ts b/src/shared/validation/schemas/volcenginePlan.ts new file mode 100644 index 0000000000..2c461c9d52 --- /dev/null +++ b/src/shared/validation/schemas/volcenginePlan.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +/** + * Request schemas for the volcengine-plan console connect routes. + * + * These bodies drive a headless browser login (phone/SMS, image captcha, + * identity selection), so every field is validated before it reaches the + * service — Hard Rule #7, enforced by `check:route-validation:t06`. + */ + +// The service forwards this straight to its own wait loops; a non-positive or +// fractional timeout is always a caller bug, never a meaningful request. +const timeoutSchema = z.number().int().positive().optional(); + +export const volcenginePlanConnectSchema = z.object({ + // Absent phone = the legacy headful flow. Present but blank is a caller bug: + // the old `body.phone.trim()` check silently fell through to that flow. + phone: z.string().trim().min(1).optional(), + timeout: timeoutSchema, +}); + +export const volcenginePlanCodeSchema = z.object({ + // Previously `String(body.code ?? "")`, which turned 123 into "123" and an + // absent code into "" — both reached the service as a plausible-looking SMS + // code and failed far away from the caller. + code: z.string().trim().min(1), + captcha: z.string().trim().min(1).optional(), + timeout: timeoutSchema, +}); + +export const volcenginePlanIdentitySchema = z.object({ + index: z.number().int().min(0), + timeout: timeoutSchema, +}); + +export type VolcenginePlanConnectBody = z.infer; +export type VolcenginePlanCodeBody = z.infer; +export type VolcenginePlanIdentityBody = z.infer; diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index f3b65fa085..316227e962 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -2308,7 +2308,14 @@ async function handleSingleModelChat( } ); - if (shouldFallback) { + // An explicit pin (combo step `connectionId` / `x-omniroute-connection`) is an + // operator instruction, not a suggestion: the account cooldown above is still + // recorded, but selection must NOT silently rotate to a sibling account of the + // same provider. Pinned steps fall through to combo orchestration, which moves + // to the next target — with ITS own pin. Same rule the antigravity + // stream-readiness / pre-response-timeout and account-semaphore paths above + // already apply. + if (shouldFallback && !hasForcedConnection) { if (Number.isFinite(cooldownMs) && cooldownMs > 0) { lastCooldownMs = cooldownMs; requestRetryLastCooldownMs = cooldownMs; diff --git a/stryker.conf.json b/stryker.conf.json index 8784091f12..79a2db685b 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -328,6 +328,8 @@ "tests/unit/repro-9486.test.ts", "tests/unit/repro-9630-combo-false-503.test.ts", "tests/unit/repro-antigravity-404-family-cooldown-hijack.test.ts", + "tests/unit/repro-combo-persisted-cooldown-preskip.test.ts", + "tests/unit/repro-glm-iso-reset-24h-cap.test.ts", "tests/unit/resilience-connections.test.ts", "tests/unit/responses-handler.test.ts", "tests/unit/responses-passthrough-openai-compatible.test.ts", diff --git a/tests/e2e/api.spec.ts b/tests/e2e/api.spec.ts index 08738d6b8b..2b3b3d8a2f 100644 --- a/tests/e2e/api.spec.ts +++ b/tests/e2e/api.spec.ts @@ -8,12 +8,26 @@ test.describe("API Health Checks", () => { expect(body).toHaveProperty("status"); }); - test("GET /api/v1/models returns model list", async ({ request }) => { + test("GET /api/v1/models returns model list or requires auth", async ({ request }) => { const res = await request.get("/api/v1/models"); - expect(res.ok()).toBeTruthy(); - const body = (await res.json()) as any; - expect(body).toHaveProperty("data"); - expect(Array.isArray(body.data)).toBe(true); + // Since #9320 the catalog requires auth whenever management auth is configured + // (unless `requireAuthForModels` is explicitly false). The E2E harness boots with + // INITIAL_PASSWORD set, so 401 is the correct, deliberate answer here — not a + // failure. The shape assertion still runs whenever the catalog IS served, which + // is what keeps this from degrading into a mere reachability check. + if (res.ok()) { + const body = (await res.json()) as any; + expect(body).toHaveProperty("data"); + expect(Array.isArray(body.data)).toBe(true); + } else { + expect([401, 403, 307]).toContain(res.status()); + if (res.status() === 401) { + // Positive anchor: it must be the catalog's auth gate answering, not some + // unrelated 401 from a misrouted request. + const body = (await res.json()) as { error?: { type?: string } }; + expect(body.error?.type).toBe("invalid_api_key"); + } + } }); test("GET /api/providers returns provider list or requires auth", async ({ request }) => { diff --git a/tests/e2e/group-b-quota-plans-config.spec.ts b/tests/e2e/group-b-quota-plans-config.spec.ts index 409e22eb9b..fa1b96499b 100644 --- a/tests/e2e/group-b-quota-plans-config.spec.ts +++ b/tests/e2e/group-b-quota-plans-config.spec.ts @@ -14,7 +14,20 @@ import { test, expect } from "@playwright/test"; import { gotoDashboardRoute } from "./helpers/dashboardAuth"; test.describe("Group B — Quota Plans Config", () => { + // Client-side exception capture. Without it a page that falls into the error + // boundary only shows up as "Internal Server Error" in the HTML, with no stack + // trace anywhere in the CI log — which is exactly how this spec's failure went + // undiagnosed for two CI rounds. + const pageErrors: string[] = []; + test.beforeEach(async ({ page }) => { + pageErrors.length = 0; + page.on("pageerror", (err) => { + pageErrors.push(`[pageerror] ${err.message}\n${err.stack ?? ""}`); + }); + page.on("console", (msg) => { + if (msg.type() === "error") pageErrors.push(`[console.error] ${msg.text()}`); + }); // Mock the plans list endpoint await page.route("**/api/quota/plans**", async (route) => { const url = new URL(route.request().url()); @@ -143,12 +156,25 @@ test.describe("Group B — Quota Plans Config", () => { } // After selection, the page should not be in a broken state. - // Note: page.content() includes the full HTML source, which contains Next.js - // chunk filenames — those hashes can legitimately contain the string "500". - // Checking for "500" in raw HTML is unreliable; instead check for the actual - // error boundary text that OmniRoute renders on unrecoverable errors - // (src/app/error.tsx heading: "Internal Server Error"). - const pageContent = await page.content(); - expect(pageContent).not.toContain("Internal Server Error"); + // + // Assert on RENDERED TEXT, not on page.content(). The raw HTML always contains + // the string, on every route, so the old assertion could never pass: layout.tsx + // hands the whole message catalogue to NextIntlClientProvider, React serialises + // that prop into the RSC payload, and en.json carries "Internal Server Error" + // twice (publicSystem.error.title and errors.500.title). Probing /dashboard, + // /dashboard/costs, /dashboard/settings and even /login all showed the string + // present in the source with the page rendering perfectly. + // + // This is the same trap that killed the sibling `not.toContain("500")` here in + // fc77100c3f ("Checking for '500' in raw HTML is unreliable") — that one was + // removed, this one was kept, and it has the identical flaw. + // + // The error boundary renders the title as visible text (src/app/error.tsx + // `

{t("error.title")}

`), so innerText still catches the real defect + // while ignoring the serialised dictionary. + const bodyText = await page.locator("body").innerText(); + expect(bodyText, `client errors:\n${pageErrors.join("\n---\n")}`).not.toContain( + "Internal Server Error" + ); }); }); diff --git a/tests/e2e/proxy-registry.smoke.spec.ts b/tests/e2e/proxy-registry.smoke.spec.ts index 7c2b087d01..6ca20efb53 100644 --- a/tests/e2e/proxy-registry.smoke.spec.ts +++ b/tests/e2e/proxy-registry.smoke.spec.ts @@ -197,7 +197,12 @@ test.describe("Proxy Registry smoke flow", () => { await expect(page.locator("table")).toContainText("http://smoke-updated.local:8080"); - await page.getByTestId("proxy-registry-open-bulk").click(); + // Bulk assign moved into the toolbar's "More actions" (⋯) overflow menu in + // #9870 — the menu only renders its items once opened, so open it first. + await page.getByTestId("proxy-registry-more-actions").click(); + const actionsMenu = page.getByRole("menu"); + await expect(actionsMenu).toBeVisible(); + await actionsMenu.getByTestId("proxy-registry-open-bulk").click(); const bulkDialog = page.getByRole("dialog"); await expect(bulkDialog.getByText("Bulk Proxy Assignment")).toBeVisible(); await bulkDialog.getByTestId("proxy-registry-bulk-scopeids-input").fill("openai,anthropic"); diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index 382dd00b6b..c17ce8eb6e 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -19,6 +19,7 @@ const { getLatestCallLog, getResponsesCallLogs } = await import("./_chatPipeline const { invalidateMemorySettingsCache } = await import("../../src/lib/memory/settings.ts"); const { skillRegistry } = await import("../../src/lib/skills/registry.ts"); const { skillExecutor } = await import("../../src/lib/skills/executor.ts"); +const { encodeSkillToolName } = await import("../../src/lib/skills/injection.ts"); const { handleChat } = await import("../../src/sse/handlers/chat.ts"); const { initTranslators } = await import("../../open-sse/translator/index.ts"); const { clearInflight } = await import("../../open-sse/services/requestDedup.ts"); @@ -726,7 +727,7 @@ test("chat pipeline applies Codex CLI fingerprint to OAuth responses requests", ); }); -test("chat pipeline strips previous_response_id from stateless Codex responses by default", async () => { +test("chat pipeline fails closed on an unresolvable previous_response_id and keeps stateless Codex responses stateless", async () => { await seedConnection("codex", { apiKey: "sk-codex-stateless-responses", providerSpecificData: { openaiStoreEnabled: false }, @@ -760,9 +761,38 @@ test("chat pipeline strips previous_response_id from stateless Codex responses b }) ); - await response.json(); + // #10262 virtualized `previous_response_id`: in any mode other than "preserve" + // the id is resolved against OmniRoute's own continuation store BEFORE routing. + // An id it cannot resolve fails closed with OpenAI's own contract instead of + // being silently stripped and forwarded as a fresh turn (which would have + // dropped the conversation history without telling the client). + const failClosed = (await response.json()) as { error?: { code?: string } }; + assert.equal(response.status, 400); + assert.equal(failClosed.error?.code, "previous_response_not_found"); + assert.equal(fetchCalls.length, 0, "a request that fails closed must not reach the upstream"); - assert.equal(response.status, 200); + // Positive anchor: the same stateless Codex connection, without the unresolvable + // continuation id, still dispatches — and the stateless contract still holds + // (store:false, no previous_response_id on the wire). + const followUp = await handleChat( + buildRequest({ + url: "http://localhost/v1/responses", + body: { + model: "codex/gpt-5.5", + stream: false, + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "First VS Code turn" }], + }, + ], + }, + }) + ); + await followUp.json(); + + assert.equal(followUp.status, 200); assert.equal(fetchCalls.length, 1); assert.match(fetchCalls[0].url, /\/responses$/); assert.equal(fetchCalls[0].body.previous_response_id, undefined); @@ -1440,13 +1470,23 @@ test("chat pipeline injects skills into tools and intercepts tool calls with ski enabled: true, }); + // #9058: provider tool names must match ^[a-zA-Z0-9_-]+$, so `name@version` + // identifiers travel base64url-encoded. Derive the expectation from the helper + // instead of pinning the encoded literal. + const expectedSkillToolName = encodeSkillToolName("lookupWeather", "1.0.0"); + assert.match(expectedSkillToolName, /^[a-zA-Z0-9_-]+$/); + assert.notEqual(expectedSkillToolName, "lookupWeather@1.0.0"); + const fetchCalls = []; globalThis.fetch = async (url, init: RequestInit = {}) => { fetchCalls.push({ url: String(url), body: init.body ? JSON.parse(String(init.body)) : null, }); - return buildOpenAIToolCallResponse(); + // #9058: the upstream echoes back exactly the tool name it was given — the + // provider-safe encoded one — so this also exercises decodeSkillToolName() + // on the interception path. + return buildOpenAIToolCallResponse({ toolName: expectedSkillToolName }); }; const response = await handleChat( @@ -1464,7 +1504,7 @@ test("chat pipeline injects skills into tools and intercepts tool calls with ski assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); assert.ok(Array.isArray(fetchCalls[0].body.tools)); - assert.equal(fetchCalls[0].body.tools[0].function.name, "lookupWeather@1.0.0"); + assert.equal(fetchCalls[0].body.tools[0].function.name, expectedSkillToolName); assert.equal(json.choices[0].finish_reason, "tool_calls"); assert.equal(json.tool_results[0].tool_call_id, "call_weather"); assert.equal(JSON.parse(json.tool_results[0].output).forecast, "Sunny in Sao Paulo"); diff --git a/tests/integration/monitoring-health-cache.test.ts b/tests/integration/monitoring-health-cache.test.ts index 3c7a66b397..3df1d63b10 100644 --- a/tests/integration/monitoring-health-cache.test.ts +++ b/tests/integration/monitoring-health-cache.test.ts @@ -22,8 +22,25 @@ process.env.JWT_SECRET = "test-health-cache-secret"; await import("../../src/lib/db/core.ts"); const { GET, DELETE } = await import("../../src/app/api/monitoring/health/route.ts"); +// GHSA-mvf8-qc78-5mxm: the detailed health payload (the one carrying `timestamp`) +// is reserved for a management principal — GET now takes the Request and an +// anonymous caller only gets the liveness verdict. Every probe below therefore +// authenticates with a dashboard-session cookie, exactly like the DELETE probe. +const { SignJWT } = await import("jose"); +const AUTH_TOKEN = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("30d") + .sign(new TextEncoder().encode(process.env.JWT_SECRET as string)); + +function authedRequest(method = "GET"): Request { + return new Request("http://localhost/api/monitoring/health", { + method, + headers: { cookie: `auth_token=${AUTH_TOKEN}` }, + }); +} + async function healthTimestamp(): Promise { - const res = await GET(); + const res = await GET(authedRequest()); const body = (await res.json()) as { timestamp?: string; adaptiveAdmission?: unknown; @@ -48,19 +65,8 @@ test("cache expires after the TTL — a fresh payload is built", async () => { }); test("DELETE (circuit-breaker reset) invalidates the cache immediately", async () => { - const { SignJWT } = await import("jose"); - const authToken = await new SignJWT({ authenticated: true }) - .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("30d") - .sign(new TextEncoder().encode(process.env.JWT_SECRET as string)); - const t1 = await healthTimestamp(); // populate cache - const delRes = await DELETE( - new Request("http://localhost/api/monitoring/health", { - method: "DELETE", - headers: { cookie: `auth_token=${authToken}` }, - }) - ); + const delRes = await DELETE(authedRequest("DELETE")); assert.ok(delRes.status < 400, `DELETE should succeed, got ${delRes.status}`); await new Promise((r) => setTimeout(r, 5)); // ensure the clock advances past ms precision const t2 = await healthTimestamp(); diff --git a/tests/integration/opencode-config-startup.test.ts b/tests/integration/opencode-config-startup.test.ts index 318bba4a3a..09161d8186 100644 --- a/tests/integration/opencode-config-startup.test.ts +++ b/tests/integration/opencode-config-startup.test.ts @@ -6,8 +6,13 @@ import path from "node:path"; import { createRequire } from "node:module"; import { after, it } from "node:test"; -const OPENCODE_VERSION = "1.18.8"; const require = createRequire(import.meta.url); +// Read the pin from the installed package instead of hard-coding it: the constant +// was frozen at 1.18.8 and silently went stale when Dependabot bumped opencode-ai +// to 1.18.18 (#10626), turning this into a base-red. Sourcing it from the resolved +// package.json keeps the assertion just as strict (the binary must report exactly +// the version this repo pins) while surviving future bumps. +const OPENCODE_VERSION: string = require("opencode-ai/package.json").version; const testHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-opencode-8849-")); const originalHome = process.env.HOME; const originalFetch = globalThis.fetch; @@ -93,10 +98,14 @@ it("#8849 generated config is accepted by pinned OpenCode schema and startup", a resolvedConfig.provider.issue8849.models["context-input-output"].limit.output, 32768 ); - assert.strictEqual( - resolvedConfig.provider.issue8849.models["no-limit-metadata"].limit, - undefined - ); + // #11035/#11032 (PR #11054) made the generator ALWAYS emit both limit keys: a model + // the catalog knows nothing about now gets the 128K context / 8K output fallbacks, + // because OpenCode's v1 provider schema rejects the whole config on a missing + // `limit.context` / `limit.output`. Before that fix the entry carried no `limit` at all. + assert.deepStrictEqual(resolvedConfig.provider.issue8849.models["no-limit-metadata"].limit, { + context: 128_000, + output: 8_192, + }); const startup = runOpencode(opencodeBinary, ["debug", "startup", "--pure"]); assert.strictEqual(startup.status, 0, startup.stderr); diff --git a/tests/integration/proxy-pipeline.test.ts b/tests/integration/proxy-pipeline.test.ts index 48e1536202..ba1d3542a8 100644 --- a/tests/integration/proxy-pipeline.test.ts +++ b/tests/integration/proxy-pipeline.test.ts @@ -37,6 +37,7 @@ describe("Chat Pipeline — handleSingleModelChat decomposition", () => { const src = readSrc("sse/handlers/chat.ts"); const helpersSrc = readSrc("sse/handlers/chatHelpers.ts"); const coreSrc = readOpenSse("handlers/chatCore.ts"); + const dispatchSrc = readSrc("sse/handlers/chatDispatch.ts"); it("should define resolveModelOrError helper", () => { assert.ok(helpersSrc, "chatHelpers.ts should exist"); @@ -66,8 +67,15 @@ describe("Chat Pipeline — handleSingleModelChat decomposition", () => { assert.match(src, /checkPipelineGates\(provider/); }); - it("handleSingleModelChat should use executeChatWithBreaker", () => { - assert.match(src, /executeChatWithBreaker\(/); + // O breaker deixou de ser chamado direto por handleSingleModelChat: a chamada foi + // extraida para o seam chatDispatch.ts (dispatchChatWithAffinityEviction). O + // invariante que este teste protege continua o mesmo — todo dispatch de chat passa + // pelo circuit breaker — mas agora precisa ser verificado nos DOIS saltos, senao a + // extracao poderia remover o breaker do caminho sem nenhum teste reclamar. + it("handleSingleModelChat should dispatch through the breaker seam", () => { + assert.match(src, /dispatchChatWithAffinityEviction\(/); + assert.ok(dispatchSrc, "src/sse/handlers/chatDispatch.ts should exist"); + assert.match(dispatchSrc, /executeChatWithBreaker\(/); }); it("chatCore should record cost for both non-streaming and streaming responses", () => { diff --git a/tests/integration/proxy-registry-flow.test.ts b/tests/integration/proxy-registry-flow.test.ts index 809d9b3466..4de8f0b1a2 100644 --- a/tests/integration/proxy-registry-flow.test.ts +++ b/tests/integration/proxy-registry-flow.test.ts @@ -176,6 +176,11 @@ test("integration: proxy registry full flow works and enforces safe delete", asy provider: "openai", }); + // #11182 made SQLite persistence a background batch (1s timer / 100-entry + // threshold); the health aggregate reads the table, so drain the queue first + // instead of racing the timer. + proxyLogger.flushProxyLogsSync(); + const healthRes = await proxyHealthRoute.GET( new Request("http://localhost/api/settings/proxies/health?hours=24") ); diff --git a/tests/integration/reasoning-routing-pipeline.test.ts b/tests/integration/reasoning-routing-pipeline.test.ts index 6d0a9b2bac..6343da23df 100644 --- a/tests/integration/reasoning-routing-pipeline.test.ts +++ b/tests/integration/reasoning-routing-pipeline.test.ts @@ -3,6 +3,10 @@ import assert from "node:assert/strict"; import { createChatPipelineHarness } from "./_chatPipelineHarness.ts"; const harness = await createChatPipelineHarness("reasoning-routing-pipeline"); +// Imported only AFTER the harness has set DATA_DIR and opened the DB: a static +// import is evaluated before any module body runs, and this module touches the +// settings/DB layer at load time. +const { getResolvedModelCapabilities } = await import("../../src/lib/modelCapabilities.ts"); const { BaseExecutor, buildOpenAIResponse, @@ -346,10 +350,20 @@ test("reasoning routing filters incompatible combo targets and rejects an empty await reasoningRulesDb.deleteReasoningRoutingRule( (await reasoningRulesDb.getReasoningRoutingRules())[0].id ); + // The target must be a model that DECLARES it cannot think (supportsThinking: + // false). `unknown` capability is deliberately kept by + // filterComboForReasoningDecision, so a model whose thinking support later + // becomes true/unknown silently stops exercising this path — assert the premise. + const INCOMPATIBLE_TARGET = "openai/gpt-4o"; + assert.equal( + getResolvedModelCapabilities(INCOMPATIBLE_TARGET).supportsThinking, + false, + `${INCOMPATIBLE_TARGET} must declare supportsThinking:false for this test to mean anything` + ); const incompatibleCombo = await combosDb.createCombo({ name: "incompatible-reasoning-combo", strategy: "priority", - models: ["antigravity/gemini-3-pro"], + models: [INCOMPATIBLE_TARGET], }); await reasoningRulesDb.createReasoningRoutingRule({ name: "Empty combo target", diff --git a/tests/integration/resilience-http-e2e.test.ts b/tests/integration/resilience-http-e2e.test.ts index 5ff70a2098..6f94fc02f6 100644 --- a/tests/integration/resilience-http-e2e.test.ts +++ b/tests/integration/resilience-http-e2e.test.ts @@ -555,12 +555,15 @@ test("resilience API only exposes configuration, not runtime breaker state", asy const { response, json } = await getJson(`${app.baseUrl}/api/resilience`); assert.equal(response.status, 200); + // Exact key set — this is the whole point of the test: configuration only. + // `providerQuotaOverrides` joined the projection in #9871. assert.deepEqual(Object.keys(json).sort(), [ "comboCooldownWait", "connectionCooldown", "legacy", "providerBreaker", "providerCooldown", + "providerQuotaOverrides", "quotaShareConcurrencyLimit", "requestQueue", "waitForCooldown", diff --git a/tests/integration/security-hardening.test.ts b/tests/integration/security-hardening.test.ts index 748b7964b3..d8ce02f874 100644 --- a/tests/integration/security-hardening.test.ts +++ b/tests/integration/security-hardening.test.ts @@ -314,19 +314,40 @@ test("OAuth routes that can create provider connections require auth guard", () for (const relPath of targets) { const content = readIfExists(relPath); assert.ok(content, `${relPath} should exist`); - if (guardDelegatingTargets.has(relPath)) { - assert.ok( - content.includes("requireOAuthImportAuth") && content.includes("requireManagementAuth"), - `${relPath} must delegate auth to requireManagementAuth via requireOAuthImportAuth` + + // Two accepted guard shapes. GHSA-mg76 moved the cursor/kiro *import* routes + // onto requireManagementAuth, which is strictly STRONGER than the legacy + // pair: it demands a management principal (dashboard session, manage-scoped + // key, CLI token) instead of merely "any authenticated caller", and answers + // 401/403 itself — so the literal "Unauthorized" no longer appears in the + // route file. The remaining routes still carry the legacy triple. + const usesManagementGuard = content.includes("requireManagementAuth(request"); + const usesLegacyGuard = + content.includes("isAuthRequired") && + content.includes("isAuthenticated") && + content.includes("Unauthorized"); + assert.ok( + usesManagementGuard || usesLegacyGuard, + `${relPath} must guard connection-creating handlers with requireManagementAuth or the isAuthRequired/isAuthenticated pair` + ); + + // Positive anchor: a guard somewhere in the file proves nothing if one of the + // exported handlers skips it. Slice the file per exported handler and require + // EACH body to await a guard on its own `request` — a guard living only in a + // helper (or in a sibling handler) no longer satisfies this. + const handlerSlices = content + .split(/(?=export\s+async\s+function\s+(?:GET|POST|PUT|PATCH|DELETE)\b)/) + .filter((slice) => + /^export\s+async\s+function\s+(?:GET|POST|PUT|PATCH|DELETE)\b/.test(slice) ); - assert.ok( - content.includes("invalidApiKeyStatus: 401"), - `${relPath} must reject anonymous requests with 401` + assert.ok(handlerSlices.length > 0, `${relPath} should export at least one HTTP handler`); + for (const slice of handlerSlices) { + const verb = /export\s+async\s+function\s+(\w+)/.exec(slice)?.[1]; + assert.match( + slice, + /await\s+(?:require\w*Auth|isAuthRequired)\s*\(\s*(?:request|req)\b/, + `${relPath}: exported handler ${verb} does not await an auth guard on its own request` ); - continue; } - assert.ok(content.includes("isAuthRequired"), `${relPath} should check whether auth is active`); - assert.ok(content.includes("isAuthenticated"), `${relPath} should require authenticated users`); - assert.ok(content.includes("Unauthorized"), `${relPath} should reject anonymous requests`); } }); diff --git a/tests/integration/skills-pipeline.test.ts b/tests/integration/skills-pipeline.test.ts index e3d3ff0bb2..0f4a99b12f 100644 --- a/tests/integration/skills-pipeline.test.ts +++ b/tests/integration/skills-pipeline.test.ts @@ -161,7 +161,7 @@ test("matching tool calls execute the registered skill and return tool results", globalThis.fetch = async () => buildOpenAIToolCallResponse({ - toolName: "lookupWeather@1.0.0", + toolName: encodeSkillToolName("lookupWeather", "1.0.0"), argumentsObject: { location: "Recife" }, }); @@ -454,8 +454,8 @@ test("responses input context participates in AUTO skill injection", async () => .map((tool) => decodeSkillToolName(tool?.function?.name ?? "")) .filter((name) => typeof name === "string" && name.length > 0); - assert.ok(names.includes("issueSearch@1.0.0")); - assert.ok(!names.includes("calendarPlanner@1.0.0")); + assert.ok(names.includes(encodeSkillToolName("issueSearch", "1.0.0"))); + assert.ok(!names.includes(encodeSkillToolName("calendarPlanner", "1.0.0"))); }); test("handleToolCallExecution() processes a tool call correctly", async () => { @@ -787,7 +787,10 @@ test("builtin and custom skills coexist in the injected tool list", async () => .sort(); assert.equal(response.status, 200); - assert.deepEqual(toolNames, ["lookupWeather@1.0.0", "webSearch@1.0.0"]); + assert.deepEqual(toolNames, [ + encodeSkillToolName("lookupWeather", "1.0.0"), + encodeSkillToolName("webSearch", "1.0.0"), + ]); }); test("web_search fallback converts built-in tools for unsupported providers and executes search", async () => { diff --git a/tests/integration/sse-correctness.test.ts b/tests/integration/sse-correctness.test.ts index d5874861ca..b901df3aaf 100644 --- a/tests/integration/sse-correctness.test.ts +++ b/tests/integration/sse-correctness.test.ts @@ -76,17 +76,43 @@ test("2. client cancel propagates to upstream (abort propagation)", async () => test("3. no leaked idle timers across N sequential streams", async () => { // createSSEStream installs a setInterval idle watchdog per stream. // If cleanup (clearInterval) does not run on stream close, timers accumulate. - // This test creates 10 streams and drains them; it acts as a smoke test that - // the process does not hang (a leaked setInterval that fires 10s later would - // prevent the test process from exiting cleanly in --test-force-exit mode). - for (let i = 0; i < 10; i++) { + // Each stream carries a real content delta: a stream whose upstream forwards + // no valuable chunk is rejected by the empty-content guard + // (open-sse/utils/streamEmptyChoices.ts) and would never reach the flush path + // whose cleanup this test is about. + // + // Drained inline, without drain()'s timeout guard: that guard leaves its own + // uncleared setTimeout behind and would drown out the very signal measured here. + const activeTimers = () => + process.getActiveResourcesInfo().filter((resource) => resource === "Timeout").length; + + const timersBefore = activeTimers(); + const N = 10; + for (let i = 0; i < N; i++) { const { up, out } = makeStream(); + up.push(`data: {"choices":[{"delta":{"content":"chunk-${i}"}}]}\n\n`); up.push("data: [DONE]\n\n"); up.close(); - await drain(out); + + const reader = out.getReader(); + const decoder = new TextDecoder(); + let text = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value); + } + // Positive anchor: the stream really ran and really closed. + assert.ok(text.includes(`chunk-${i}`), `stream ${i} lost its content: ${JSON.stringify(text)}`); } - // If we reach here without a timeout, no blocking resources were leaked. - assert.ok(true, "all 10 streams completed without hanging"); + + // The watchdog of every closed stream must have been cleared. One slot of slack + // absorbs unrelated runtime timers, but N leaked watchdogs cannot hide in it. + const timersAfter = activeTimers(); + assert.ok( + timersAfter <= timersBefore + 1, + `idle watchdog timers leaked across ${N} streams: ${timersBefore} active before, ${timersAfter} after` + ); }); test("4. final snapshot does not duplicate tail text", async () => { diff --git a/tests/integration/v1-contracts-behavior.test.ts b/tests/integration/v1-contracts-behavior.test.ts index 210452dc7f..fc1ea1cd1e 100644 --- a/tests/integration/v1-contracts-behavior.test.ts +++ b/tests/integration/v1-contracts-behavior.test.ts @@ -3,6 +3,31 @@ import assert from "node:assert/strict"; const BASE_URL = "http://localhost:20128"; +// #9320 (`fix(security): require auth for /v1/models when management auth is +// configured`) inverted the default: the `/v1` catalog reads are now gated +// whenever `isAuthRequired()` is true instead of only when +// `settings.requireAuthForModels === true`. The integration CI job sets +// `INITIAL_PASSWORD`, which flips `isAuthRequired()` on, so unauthenticated +// catalog reads answer 401 there while they answer 200 on a bare dev box. +// These are SHAPE contracts, so authenticate them with the deployment env key +// (`isConfiguredEnvApiKey` → `validateApiKey` returns true) and let +// tests/unit/v1-models-auth-leak-9320.test.ts own the auth-gate contract. +const TEST_API_KEY = "sk-v1-contracts-behavior-test-key"; +const previousEnvApiKey = process.env.OMNIROUTE_API_KEY; +process.env.OMNIROUTE_API_KEY = TEST_API_KEY; + +test.after(() => { + if (previousEnvApiKey === undefined) delete process.env.OMNIROUTE_API_KEY; + else process.env.OMNIROUTE_API_KEY = previousEnvApiKey; +}); + +function authedRequest(path: string): Request { + return new Request(`${BASE_URL}${path}`, { + method: "GET", + headers: { Authorization: `Bearer ${TEST_API_KEY}` }, + }); +} + test("contract: /api/v1 OPTIONS exposes CORS and allowed methods", async () => { const { OPTIONS } = await import("../../src/app/api/v1/route.ts"); const response = await OPTIONS(); @@ -29,8 +54,8 @@ test("contract: /api/v1 and /api/v1/models return consistent model IDs", async ( ]); const [v1Response, v1ModelsResponse] = await Promise.all([ - getV1(new Request(`${BASE_URL}/api/v1`, { method: "GET" })), - getV1Models(new Request(`${BASE_URL}/api/v1/models`, { method: "GET" })), + getV1(authedRequest("/api/v1")), + getV1Models(authedRequest("/api/v1/models")), ]); assert.equal(v1Response.status, 200); @@ -52,7 +77,7 @@ test("contract: /api/v1 and /api/v1/models return consistent model IDs", async ( test("contract: /api/v1/models returns OpenAI-compatible model shape", async () => { const { GET: getV1Models } = await import("../../src/app/api/v1/models/route.ts"); - const response = await getV1Models(new Request(`${BASE_URL}/api/v1/models`, { method: "GET" })); + const response = await getV1Models(authedRequest("/api/v1/models")); assert.equal(response.status, 200); const body = (await response.json()) as any; @@ -72,7 +97,7 @@ test("contract: /api/v1/models returns OpenAI-compatible model shape", async () test("contract: /api/v1/embeddings GET returns embedding model listing shape", async () => { const { GET: getEmbeddings } = await import("../../src/app/api/v1/embeddings/route.ts"); - const response = await getEmbeddings(); + const response = await getEmbeddings(authedRequest("/api/v1/embeddings")); assert.equal(response.status, 200); const body = (await response.json()) as any; @@ -91,7 +116,7 @@ test("contract: /api/v1/embeddings GET returns embedding model listing shape", a test("contract: /api/v1/images/generations GET returns image model listing shape", async () => { const { GET: getImageModels } = await import("../../src/app/api/v1/images/generations/route.ts"); - const response = await getImageModels(); + const response = await getImageModels(authedRequest("/api/v1/images/generations")); assert.equal(response.status, 200); const body = (await response.json()) as any; diff --git a/tests/unit/AutoComboCatalog.test.tsx b/tests/unit/AutoComboCatalog.test.tsx index 62f3872926..d7ffd06f1a 100644 --- a/tests/unit/AutoComboCatalog.test.tsx +++ b/tests/unit/AutoComboCatalog.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { act } from "react"; import { createRoot } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTO_COMBO_TEMPLATES } from "@/domain/assessment/types"; // Minimal i18n stub — return interpolated value so {count} works. @@ -27,10 +27,21 @@ function makeContainer(): HTMLElement { } // The component pulls a heavy dependency graph (Card + i18n), so the cold -// module import in the first test takes ~20s of transform overhead. Sibling -// tests (agent-card.test.tsx) use a 30s timeout for the same reason; the -// import must settle before any render assertions can run. +// module import takes ~20s of transform overhead — and well past 60s when the +// full vitest UI suite runs its 20 workers in parallel. That cost used to be +// charged to whichever test imported first: it blew the per-test timeout, and +// the abort landed *inside* an open `act()`, leaking an unbalanced act scope +// that then failed every remaining test in the file in ~20ms ("You seem to have +// overlapping act() calls"). Paying the import once here, on the hook's own +// budget, keeps each test's timeout covering only render + assertions. +let AutoComboCatalog: React.ComponentType<{ onComboCreated?: (comboId: string) => void }>; + describe("AutoComboCatalog", { timeout: 60_000 }, () => { + beforeAll(async () => { + ({ default: AutoComboCatalog } = + await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog")); + }, 180_000); + beforeEach(() => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -45,8 +56,6 @@ describe("AutoComboCatalog", { timeout: 60_000 }, () => { }); it("renders the header with translated title and template-count badge", async () => { - const { default: AutoComboCatalog } = - await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog"); const container = makeContainer(); const root = createRoot(container); await act(async () => { @@ -59,20 +68,20 @@ describe("AutoComboCatalog", { timeout: 60_000 }, () => { }); it("stays collapsed by default — no template rows in the DOM", async () => { - const { default: AutoComboCatalog } = - await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog"); const container = makeContainer(); const root = createRoot(container); await act(async () => { root.render(); }); + // Absence alone is vacuously true on a container that never mounted — this + // test stayed green through the act-leak that failed the other four. Pin the + // header first so "no rows" can only mean collapsed, never "nothing rendered". + expect(container.textContent ?? "").toContain("autoCatalogTitle"); const first = AUTO_COMBO_TEMPLATES[0]; expect(container.textContent ?? "").not.toContain(first.name); }); it("expands when toggled and lists every template name", async () => { - const { default: AutoComboCatalog } = - await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog"); const container = makeContainer(); const root = createRoot(container); await act(async () => { @@ -89,8 +98,6 @@ describe("AutoComboCatalog", { timeout: 60_000 }, () => { }); it("flips the toggle aria-label between expand and collapse", async () => { - const { default: AutoComboCatalog } = - await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog"); const container = makeContainer(); const root = createRoot(container); await act(async () => { @@ -107,8 +114,6 @@ describe("AutoComboCatalog", { timeout: 60_000 }, () => { }); it("renders the strategy badge for each template when expanded", async () => { - const { default: AutoComboCatalog } = - await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog"); const container = makeContainer(); const root = createRoot(container); await act(async () => { diff --git a/tests/unit/a2a-v1-compat-10839.test.ts b/tests/unit/a2a-v1-compat-10839.test.ts index 075a3e2936..7aeea86542 100644 --- a/tests/unit/a2a-v1-compat-10839.test.ts +++ b/tests/unit/a2a-v1-compat-10839.test.ts @@ -14,6 +14,19 @@ const settingsDb = await import("../../src/lib/db/settings.ts"); const a2aRoute = await import("../../src/app/a2a/route.ts"); const agentCardRoute = await import("../../src/app/.well-known/agent-card.json/route.ts"); +/** + * The agent-card routes derive their base URL from `request.nextUrl.origin` + * (S2 topology sanitisation, #11418), so the handler must be invoked with a + * request the way Next.js does — a bare `GET()` throws on `nextUrl`. + */ +function makeCardRequest( + url = "https://gateway.example.com/.well-known/agent-card.json" +): NextRequest { + const request = new Request(url) as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { value: new URL(url), configurable: true }); + return request; +} + function makeJsonRpcRequest(body: unknown): NextRequest { return new Request("http://localhost/a2a", { method: "POST", @@ -48,7 +61,13 @@ test("#10839: v1.0 SendMessage is aliased to message/send and reshapes the respo ); assert.equal(res.status, 200); const body = (await res.json()) as { - result?: { task?: { id: string; status?: { message?: { parts?: { text?: string }[] } }; artifacts?: unknown } }; + result?: { + task?: { + id: string; + status?: { message?: { parts?: { text?: string }[] } }; + artifacts?: unknown; + }; + }; error?: unknown; }; assert.equal(body.error, undefined, JSON.stringify(body)); @@ -102,7 +121,7 @@ test("#10839: SendStreamingMessage no longer 404s (aliased to message/stream)", }); test("#10839: GET /.well-known/agent-card.json serves a v1.0 card declaring both interfaces", async () => { - const res = await agentCardRoute.GET(); + const res = await agentCardRoute.GET(makeCardRequest()); assert.equal(res.status, 200); const card = (await res.json()) as { supportedInterfaces?: { protocolVersion?: string }[] }; assert.ok(Array.isArray(card.supportedInterfaces)); diff --git a/tests/unit/agent-card-route.test.ts b/tests/unit/agent-card-route.test.ts index 2dc714eeac..a9ba0d8dca 100644 --- a/tests/unit/agent-card-route.test.ts +++ b/tests/unit/agent-card-route.test.ts @@ -8,9 +8,21 @@ import test from "node:test"; import assert from "node:assert/strict"; +import type { NextRequest } from "next/server"; const { GET } = await import("../../src/app/.well-known/agent.json/route.js"); +/** + * The agent-card routes derive their base URL from `request.nextUrl.origin` + * (S2 topology sanitisation, #11418), so the handler must be invoked with a + * request the way Next.js does — a bare `GET()` throws on `nextUrl`. + */ +function makeCardRequest(url = "https://gateway.example.com/.well-known/agent.json"): NextRequest { + const request = new Request(url) as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { value: new URL(url), configurable: true }); + return request; +} + interface AgentSkillEntry { id: string; name: string; @@ -26,7 +38,7 @@ interface AgentCard { } test("GET /.well-known/agent.json returns 6 skills", async () => { - const response = await GET(); + const response = await GET(makeCardRequest()); assert.equal(response.status, 200, "Expected HTTP 200"); const body = (await response.json()) as AgentCard; @@ -35,7 +47,7 @@ test("GET /.well-known/agent.json returns 6 skills", async () => { }); test("Agent Card includes list-capabilities skill entry", async () => { - const response = await GET(); + const response = await GET(makeCardRequest()); const body = (await response.json()) as AgentCard; const skill = body.skills.find((s) => s.id === "list-capabilities"); @@ -43,7 +55,7 @@ test("Agent Card includes list-capabilities skill entry", async () => { }); test("list-capabilities entry has required tags [discovery, capabilities]", async () => { - const response = await GET(); + const response = await GET(makeCardRequest()); const body = (await response.json()) as AgentCard; const skill = body.skills.find((s) => s.id === "list-capabilities"); @@ -54,7 +66,7 @@ test("list-capabilities entry has required tags [discovery, capabilities]", asyn }); test("list-capabilities entry has at least one example question", async () => { - const response = await GET(); + const response = await GET(makeCardRequest()); const body = (await response.json()) as AgentCard; const skill = body.skills.find((s) => s.id === "list-capabilities"); @@ -64,7 +76,7 @@ test("list-capabilities entry has at least one example question", async () => { }); test("Agent Card includes all 5 original skills", async () => { - const response = await GET(); + const response = await GET(makeCardRequest()); const body = (await response.json()) as AgentCard; const originalIds = [ @@ -78,7 +90,7 @@ test("Agent Card includes all 5 original skills", async () => { for (const id of originalIds) { assert.ok( body.skills.some((s) => s.id === id), - `Original skill '${id}' must be present in Agent Card`, + `Original skill '${id}' must be present in Agent Card` ); } }); diff --git a/tests/unit/agent-skills-page.test.tsx b/tests/unit/agent-skills-page.test.tsx index b315e71806..1b241c4480 100644 --- a/tests/unit/agent-skills-page.test.tsx +++ b/tests/unit/agent-skills-page.test.tsx @@ -92,14 +92,16 @@ function make42Skills(): AgentSkill[] { const FULL_COVERAGE: SkillCoverage = { api: { have: 22, total: 22 }, cli: { have: 20, total: 20 }, - totalSkills: 42, + config: { have: 2, total: 2 }, + totalSkills: 44, generatedAt: new Date().toISOString(), }; const PARTIAL_COVERAGE: SkillCoverage = { api: { have: 10, total: 22 }, cli: { have: 8, total: 20 }, - totalSkills: 18, + config: { have: 1, total: 2 }, + totalSkills: 19, generatedAt: new Date().toISOString(), }; @@ -328,14 +330,17 @@ describe("AgentSkillsPageClient", () => { expect(coverageBar).not.toBeNull(); const progressBars = container.querySelectorAll("[role='progressbar']"); - expect(progressBars.length).toBe(2); + // A ordem de render em CoverageBar e api -> config -> cli, entao o indice do CLI + // acompanha a barra de config; sem isso [1] passaria a apontar para config e a + // assercao do CLI ficaria verde medindo a barra errada. + expect(progressBars.length).toBe(3); // API bar — 22/22 = 100%, should have emerald color class const apiBar = progressBars[0] as HTMLElement; expect(apiBar.className).toContain("bg-emerald-500"); // CLI bar — 20/20 = 100%, should have emerald color class - const cliBar = progressBars[1] as HTMLElement; + const cliBar = progressBars[2] as HTMLElement; expect(cliBar.className).toContain("bg-emerald-500"); }); @@ -425,7 +430,7 @@ describe("AgentSkillsPageClient", () => { // ── CoverageBar isolated tests ─────────────────────────────────────────────── describe("CoverageBar", () => { - it("renders two progressbars with correct aria attributes", async () => { + it("renders three progressbars with correct aria attributes", async () => { const { CoverageBar } = await import("../../src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar"); const container = makeContainer(); @@ -435,13 +440,20 @@ describe("CoverageBar", () => { }); const bars = container.querySelectorAll("[role='progressbar']"); - expect(bars.length).toBe(2); + // api -> config -> cli. A barra de config entrou no MEIO, entao o indice do CLI + // desloca junto; conferir as tres aqui e o que impede um indice errado de passar + // despercebido medindo a barra vizinha. + expect(bars.length).toBe(3); const apiBar = bars[0] as HTMLElement; expect(apiBar.getAttribute("aria-valuenow")).toBe("22"); expect(apiBar.getAttribute("aria-valuemax")).toBe("22"); - const cliBar = bars[1] as HTMLElement; + const configBar = bars[1] as HTMLElement; + expect(configBar.getAttribute("aria-valuenow")).toBe("2"); + expect(configBar.getAttribute("aria-valuemax")).toBe("2"); + + const cliBar = bars[2] as HTMLElement; expect(cliBar.getAttribute("aria-valuenow")).toBe("20"); expect(cliBar.getAttribute("aria-valuemax")).toBe("20"); @@ -454,6 +466,7 @@ describe("CoverageBar", () => { const lowCoverage: SkillCoverage = { api: { have: 5, total: 22 }, cli: { have: 0, total: 20 }, + config: { have: 0, total: 2 }, totalSkills: 5, generatedAt: new Date().toISOString(), }; @@ -477,7 +490,8 @@ describe("CoverageBar", () => { const partialCoverage: SkillCoverage = { api: { have: 18, total: 22 }, // ~81.8% = amber cli: { have: 15, total: 20 }, // 75% = amber - totalSkills: 33, + config: { have: 3, total: 4 }, // 75% = amber + totalSkills: 36, generatedAt: new Date().toISOString(), }; diff --git a/tests/unit/antigravity-oauth-postexchange-nonblocking.test.ts b/tests/unit/antigravity-oauth-postexchange-nonblocking.test.ts index 62104d7412..8a54b49f61 100644 --- a/tests/unit/antigravity-oauth-postexchange-nonblocking.test.ts +++ b/tests/unit/antigravity-oauth-postexchange-nonblocking.test.ts @@ -152,7 +152,12 @@ test("postExchange attempts onboarding when projectId is empty and returns disco } if (u.includes("onboardUser")) { onboardUserCalled = true; - return jsonRes({ done: true }); + // Real onboarding success: the body carries a cloudaicompanionProject. + // #11284 made that the discriminator — a 200 WITHOUT one is Google BYOP + // (no project was created, none ever will be) and short-circuits before + // the retry. The id here is deliberately NOT the expected one, so the + // assertion below still proves the value came from the retry discovery. + return jsonRes({ done: true, cloudaicompanionProject: "onboard-body-project" }); } return jsonRes({}); }) as typeof fetch; diff --git a/tests/unit/authz/public-policy.test.ts b/tests/unit/authz/public-policy.test.ts index 1ebffc963b..527801476e 100644 --- a/tests/unit/authz/public-policy.test.ts +++ b/tests/unit/authz/public-policy.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { publicPolicy } from "../../../src/server/authz/policies/public.ts"; import type { PolicyContext } from "../../../src/server/authz/context.ts"; +import { getMachineTokenSync } from "../../../src/lib/machineToken.ts"; function ctx(): PolicyContext { return { @@ -20,3 +21,58 @@ test("publicPolicy always allows with anonymous subject", async () => { assert.equal(out.subject.id, "anonymous"); } }); + +/** + * `runAuthzPipeline` strips CLI_TOKEN_HEADER for EVERY route class, so a + * PUBLIC-classified route that still serves a reduced anonymous view (GET + * /api/monitoring/health, GHSA-mvf8-qc78-5mxm) can only recognize the local CLI + * through the subject this policy stamps. Without it the packaged CLI was + * permanently anonymous there and the health payload lost `version` — which is + * exactly what check:pack-boot asserts. + */ +function cliCtx(overrides: Partial = {}): PolicyContext { + return { + request: { + method: "GET", + headers: new Headers({ "x-omniroute-cli-token": getMachineTokenSync() }), + ip: "127.0.0.1", + ...overrides, + }, + classification: { + routeClass: "PUBLIC", + reason: "public_prefix", + normalizedPath: "/api/monitoring/health", + }, + requestId: "req_cli", + }; +} + +test("publicPolicy stamps the local-CLI subject for a valid loopback machine token", async () => { + // No environment guard here on purpose: this and the negative control below are + // the only tests covering the loopback branch added for check:pack-boot, and a + // conditional skip would silence them exactly where the coverage matters. The + // sibling management-policy tests in authz/routeGuard.test.ts call this same + // helper unguarded, so an empty token is a broken environment worth failing on. + assert.ok(getMachineTokenSync(), "machine token must resolve for this suite to mean anything"); + const out = await publicPolicy.evaluate(cliCtx()); + assert.equal(out.allow, true); + if (out.allow) { + assert.equal(out.subject.kind, "management_key"); + assert.equal(out.subject.label, "local-cli-token"); + } +}); + +test("publicPolicy keeps anonymous for a non-loopback peer carrying the token", async () => { + assert.ok(getMachineTokenSync(), "machine token must resolve for this suite to mean anything"); + const out = await publicPolicy.evaluate(cliCtx({ ip: "203.0.113.7" })); + assert.equal(out.allow, true); + if (out.allow) assert.equal(out.subject.kind, "anonymous"); +}); + +test("publicPolicy keeps anonymous for a wrong token from loopback", async () => { + const out = await publicPolicy.evaluate( + cliCtx({ headers: new Headers({ "x-omniroute-cli-token": "0".repeat(64) }) }) + ); + assert.equal(out.allow, true); + if (out.allow) assert.equal(out.subject.kind, "anonymous"); +}); diff --git a/tests/unit/cli-oneproxy-commands.test.ts b/tests/unit/cli-oneproxy-commands.test.ts index 0a30ac621b..a1b2c5fbe6 100644 --- a/tests/unit/cli-oneproxy-commands.test.ts +++ b/tests/unit/cli-oneproxy-commands.test.ts @@ -11,14 +11,33 @@ function makeCmd(output = "json") { } test("oneproxy status chama omniroute_oneproxy_stats via MCP", async () => { + // #10960 rewrote this test around the shared stream mock but left it asserting + // `calls.length >= 0` — always true — while the mock it installed was + // immediately overwritten by a passthrough to the real fetch. Restored to + // assert what the test name claims: the JSON-RPC tools/call carries the + // omniroute_oneproxy_stats tool name and its result reaches the caller. + // Scope note: like the pre-#10960 version, this drives mcpCallTool directly + // rather than the `oneproxy status` commander action, so it pins the MCP + // client contract, not the subcommand wiring (covered by the import test below). + const toolCalls: Array> = []; const origFetch = globalThis.fetch; - globalThis.fetch = makeMcpStreamFetch({ toolResult: { poolSize: 10, activeProxies: 8 } }); - const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); - const result = await mcpCallTool("omniroute_oneproxy_stats", {}); - globalThis.fetch = origFetch; - assert.equal((result as any).poolSize, 10); - assert.equal((result as any).activeProxies, 8); - assert.ok("poolSize" in (result as object) && "activeProxies" in (result as object)); + const streamFetch = makeMcpStreamFetch({ toolResult: { poolSize: 10, activeProxies: 8 } }); + globalThis.fetch = (async (url: string | URL, init?: any) => { + const parsed = init?.body ? JSON.parse(init.body) : {}; + if (parsed.method === "tools/call") toolCalls.push(parsed.params ?? {}); + return streamFetch(url as string, init); + }) as any; + + try { + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + const result = await mcpCallTool("omniroute_oneproxy_stats", {}); + assert.deepEqual(result, { poolSize: 10, activeProxies: 8 }); + } finally { + globalThis.fetch = origFetch; + } + + assert.equal(toolCalls.length, 1, "exactly one tools/call must reach the MCP endpoint"); + assert.equal(toolCalls[0].name, "omniroute_oneproxy_stats"); }); test("oneproxy stats passa provider e period para MCP", async () => { diff --git a/tests/unit/conductor-agent-card.test.ts b/tests/unit/conductor-agent-card.test.ts index 079efff9e8..d078493a82 100644 --- a/tests/unit/conductor-agent-card.test.ts +++ b/tests/unit/conductor-agent-card.test.ts @@ -2,9 +2,22 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createServer, type Server } from "node:http"; +import type { NextRequest } from "next/server"; + import { GET } from "../../src/app/.well-known/agent.json/route.ts"; import { clearFleetSkillsCache } from "../../src/lib/conductor/fleetSkills.ts"; +/** + * The agent-card routes derive their base URL from `request.nextUrl.origin` + * (S2 topology sanitisation, #11418), so the handler must be invoked with a + * request the way Next.js does — a bare `GET()` throws on `nextUrl`. + */ +function makeCardRequest(url = "https://gateway.example.com/.well-known/agent.json"): NextRequest { + const request = new Request(url) as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { value: new URL(url), configurable: true }); + return request; +} + const servers: Server[] = []; test.beforeEach(() => { @@ -22,7 +35,7 @@ test.after(async () => { }); test("sem CONDUCTOR_HUB_URL o card continua válido, com as skills estáticas e zero conductor-*", async () => { - const res = await GET(); + const res = await GET(makeCardRequest()); const card = await res.json(); assert.equal(typeof card.name, "string"); assert.ok(Array.isArray(card.skills) && card.skills.length >= 6, "skills estáticas presentes"); @@ -34,7 +47,11 @@ test("com hub de pé o card anuncia as skills da frota SEM perder as estáticas" res.writeHead(200, { "content-type": "application/json" }); res.end( JSON.stringify([ - { id: "r_1", online: true, capabilities: { name: "devbox", clis: [{ profile: "claude" }], skills: [] } }, + { + id: "r_1", + online: true, + capabilities: { name: "devbox", clis: [{ profile: "claude" }], skills: [] }, + }, ]) ); }); @@ -44,7 +61,7 @@ test("com hub de pé o card anuncia as skills da frota SEM perder as estáticas" process.env.CONDUCTOR_HUB_URL = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`; process.env.CONDUCTOR_HUB_TOKEN = "tok"; - const res = await GET(); + const res = await GET(makeCardRequest()); const card = await res.json(); const ids = card.skills.map((s: { id: string }) => s.id); assert.ok(ids.includes("conductor-cli-claude"), `frota anunciada (ids: ${ids.join(",")})`); diff --git a/tests/unit/container-env-detect.test.ts b/tests/unit/container-env-detect.test.ts index 4e963fd514..2129e0efff 100644 --- a/tests/unit/container-env-detect.test.ts +++ b/tests/unit/container-env-detect.test.ts @@ -146,6 +146,40 @@ test("hasBindMountAt is false for an unmounted container path", () => { assert.equal(hasBindMountAt("/opt/whatever", mountDeps(HOST_PROFILE_MOUNTINFO)), false); }); +test("hasBindMountAt ignores tmpfs and other in-memory mounts", () => { + // A `--tmpfs /tmp` (or a container whose /tmp is tmpfs) is throwaway storage, + // not a route to the host: counting it would clear the ephemeral flag for a + // path that loses the file even before the container is recreated. + const mountinfo = [ + "99 30 0:36 / /tmp rw,relatime shared:25 - tmpfs tmpfs rw,size=12582912k,inode64", + "32 27 0:27 / /dev/shm rw,nosuid,nodev shared:4 - tmpfs tmpfs rw,inode64", + "26 30 0:24 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw", + "", + ].join("\n"); + assert.equal(hasBindMountAt("/tmp", mountDeps(mountinfo)), false); + assert.equal(hasBindMountAt("/tmp/omniroute-fake-home/.codex", mountDeps(mountinfo)), false); + assert.equal(hasBindMountAt("/dev/shm/whatever", mountDeps(mountinfo)), false); + assert.equal(hasBindMountAt("/proc/1", mountDeps(mountinfo)), false); +}); + +test("hasBindMountAt still honours a real bind mount nested under a tmpfs path", () => { + const mountinfo = [ + "99 30 0:36 / /tmp rw,relatime - tmpfs tmpfs rw,inode64", + "44 99 254:1 /Users/me/.codex /tmp/host-home/.codex rw,relatime - ext4 /dev/vda1 rw", + "", + ].join("\n"); + assert.equal(hasBindMountAt("/tmp/host-home/.codex", mountDeps(mountinfo)), true); + assert.equal(hasBindMountAt("/tmp/host-home", mountDeps(mountinfo)), true); + assert.equal(hasBindMountAt("/tmp/other", mountDeps(mountinfo)), false); +}); + +test("hasBindMountAt skips a mountinfo line with no filesystem-type separator", () => { + // Without the trailing "- ..." section the line proves nothing, so + // it must not be read as a host mount. + const mountinfo = "44 28 254:1 / /host-home rw,relatime shared:1\n"; + assert.equal(hasBindMountAt("/host-home", mountDeps(mountinfo)), false); +}); + test("hasBindMountAt never treats / as a bind mount", () => { assert.equal(hasBindMountAt("/", mountDeps(HOST_PROFILE_MOUNTINFO)), false); }); diff --git a/tests/unit/context-handoff.test.ts b/tests/unit/context-handoff.test.ts index 845f118a5e..a19b8734a8 100644 --- a/tests/unit/context-handoff.test.ts +++ b/tests/unit/context-handoff.test.ts @@ -420,3 +420,106 @@ test("selectMessagesForSummary with no system messages and oversized single rema .join("\n\n"); assert.ok(historyText.length > 0, "historyText must be non-empty so the handoff is generated"); }); + +// ── #11552: universal-handoff regeneration backoff ─────────────────────────── +// A switch-heavy combo strategy (weighted / random / round-robin) alternates +// models on almost every turn, so `maybeGenerateUniversalHandoff` is consulted +// constantly. When the summarizer answers with something that is not a usable +// handoff, nothing is persisted — and before the fix the very next switch +// re-issued the same full-history summarization call and discarded the answer +// again, on and on. That is the extra upstream call issue #11552 measured. + +function universalHandoffOptions(sessionId, handleSingleModel) { + return { + sessionId, + comboName: "weighted-combo", + messages: [{ role: "user", content: "Ship the weighted combo fix" }], + prevModel: "openai/gpt-4o-mini", + currModel: "claude/claude-3-5-sonnet-20241022", + universalConfig: contextHandoff.resolveUniversalHandoffConfig(null, null), + handleSingleModel, + }; +} + +function handoffJSONResponse(summary) { + return new Response( + JSON.stringify({ + choices: [ + { + message: { + content: JSON.stringify({ + summary, + keyDecisions: ["backoff on unparseable handoffs"], + taskProgress: "done", + activeEntities: ["contextHandoff.ts"], + }), + }, + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); +} + +test("maybeGenerateUniversalHandoff stops re-summarizing after an unparseable answer", async () => { + contextHandoff.resetUniversalHandoffCooldowns(); + let calls = 0; + // 200 upstream OK responses that carry no handoff JSON — exactly what the + // weighted combo matrix sees. + const options = universalHandoffOptions("sess-unparseable", async () => { + calls += 1; + return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + for (let i = 0; i < 200; i++) { + contextHandoff.maybeGenerateUniversalHandoff(options); + await new Promise((resolve) => setTimeout(resolve, 1)); + } + + // Positive anchor: the feature still runs — the first switch DID generate. + assert.equal(calls, 1, `expected exactly one summarization call, got ${calls}`); + assert.equal(handoffDb.getHandoff("sess-unparseable", "weighted-combo"), null); +}); + +test("maybeGenerateUniversalHandoff still generates and persists a usable handoff", async () => { + contextHandoff.resetUniversalHandoffCooldowns(); + let calls = 0; + const options = universalHandoffOptions("sess-usable", async () => { + calls += 1; + return handoffJSONResponse("Weighted combo handoff"); + }); + + contextHandoff.maybeGenerateUniversalHandoff(options); + const saved = await waitFor(() => handoffDb.getHandoff("sess-usable", "weighted-combo")); + assert.ok(saved, "a parseable summary must still be persisted"); + assert.equal(saved.summary, "Weighted combo handoff"); + assert.equal(calls, 1); + + // A persisted handoff makes the next switch "inject", not "generate". + contextHandoff.maybeGenerateUniversalHandoff(options); + await new Promise((resolve) => setTimeout(resolve, 40)); + assert.equal(calls, 1); +}); + +test("a transient upstream failure does not arm the unparseable backoff", async () => { + contextHandoff.resetUniversalHandoffCooldowns(); + let calls = 0; + const options = universalHandoffOptions("sess-transient", async () => { + calls += 1; + if (calls === 1) return new Response("upstream down", { status: 503 }); + return handoffJSONResponse("Recovered handoff"); + }); + + contextHandoff.maybeGenerateUniversalHandoff(options); + await new Promise((resolve) => setTimeout(resolve, 40)); + assert.equal(handoffDb.getHandoff("sess-transient", "weighted-combo"), null); + + contextHandoff.maybeGenerateUniversalHandoff(options); + const saved = await waitFor(() => handoffDb.getHandoff("sess-transient", "weighted-combo")); + assert.ok(saved, "a 503 must stay retryable on the next model switch"); + assert.equal(saved.summary, "Recovered handoff"); + assert.equal(calls, 2); +}); diff --git a/tests/unit/electron-packaging.test.ts b/tests/unit/electron-packaging.test.ts index 1e231f9aca..e08a2f9ee9 100644 --- a/tests/unit/electron-packaging.test.ts +++ b/tests/unit/electron-packaging.test.ts @@ -7,7 +7,29 @@ import { pruneElectronRuntimeDocs } from "../../scripts/build/electronRuntimeDoc const ROOT = join(import.meta.dirname, "..", ".."); -test("electron build copies the standalone runtime into resources/app exactly once", () => { +// The SECOND entry looks like a redundant duplicate of the first — it is not, and +// removing it ships a desktop app that cannot boot. +// +// electron-builder's file matcher hard-codes an exclusion of the source root's +// `node_modules` directory for extraResources/extraFiles, BEFORE any `filter` +// pattern is consulted (app-builder-lib/out/util/filter.js: `if (relative === +// "node_modules") return false`). So `{ from: ".build/electron-standalone", to: +// "app", filter: ["**/*"] }` copies server.js, server-ws.mjs and every NESTED +// node_modules, but silently drops `.build/electron-standalone/node_modules` — +// the tree that holds `next`, `better-sqlite3` and the whole server closure. +// +// Pointing a second matcher AT the node_modules directory sidesteps the check +// (its relative paths never equal "node_modules") and is the only way to get that +// tree into `resources/app/node_modules`, which main.js also puts on the server's +// NODE_PATH. +// +// Regression history: #10325 "de-duplicated" the two entries into one on +// 2026-08-16; the packaged app then died on `Cannot find module 'next'` at +// resources/app/server.js. It went unnoticed for nine days because the Electron +// Package Smoke was already red on an earlier defect (lib/loginHeaderCapture.js +// missing from build.files since #9984), so the main process never got far enough +// to spawn the server. +test("electron build copies the standalone runtime AND its root node_modules into resources/app", () => { const electronPackage = JSON.parse(readFileSync(join(ROOT, "electron", "package.json"), "utf8")); const extraResources = electronPackage.build?.extraResources; @@ -21,6 +43,11 @@ test("electron build copies the standalone runtime into resources/app exactly on { from: "../.build/electron-standalone", to: "app", + filter: ["**/*", "node_modules/**/*"], + }, + { + from: "../.build/electron-standalone/node_modules", + to: "app/node_modules", filter: ["**/*"], }, ]); diff --git a/tests/unit/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts index 976e77662e..82f95ff822 100644 --- a/tests/unit/hard-session-lease-bypass-inventory.test.ts +++ b/tests/unit/hard-session-lease-bypass-inventory.test.ts @@ -147,6 +147,14 @@ const EXPECTED: Record> = { "src/lib/oauth/utils/codexAuthImport.ts": 1, "src/lib/providerModels/managedModelImport.ts": 1, "src/lib/providers/codexConnectionDefaults.ts": 1, + // Volcano Ark plan connect flow (commit d732cf615): both are connection *persistence* + // sites, not dispatch. volcenginePlanBinding looks the plan connection up by name to + // decide update-vs-create during connect (same shape as oauth/connectionPersistence); + // volcPlanAutoSyncBackfill is a one-shot boot backfill that patches a providerSpecificData + // flag and issues no upstream call. Neither selects a connection to serve a request, so + // both stay class C (see CLASSIFICATION below). + "src/lib/providers/volcPlanAutoSyncBackfill.ts": 1, + "src/lib/providers/volcenginePlanBinding.ts": 1, "src/lib/proxyEgress.ts": 1, "src/lib/quota/connectionRecovery.ts": 2, "src/lib/sync/bundle.ts": 1, @@ -194,6 +202,7 @@ const CLASSIFICATION: Record> = { [ "open-sse/handlers/autoComboCandidates.ts", "open-sse/handlers/chatCore.ts", + "open-sse/services/combo.ts", "open-sse/services/alibabaFreeTier.ts", "open-sse/services/alibabaFreeTierQuotaFetcher.ts", "open-sse/services/combo.ts", diff --git a/tests/unit/monitoring-health-public-view.test.ts b/tests/unit/monitoring-health-public-view.test.ts index 0f8f7b2209..5b7249066d 100644 --- a/tests/unit/monitoring-health-public-view.test.ts +++ b/tests/unit/monitoring-health-public-view.test.ts @@ -46,3 +46,27 @@ test("management session sees the full health payload", async () => { "a management caller must still receive the detailed payload" ); }); + +/** + * The local CLI reaches this PUBLIC-classified route with the machine + * token, which `runAuthzPipeline` converts into the trusted subject stamp below + * (the raw header is always stripped). The full payload — `version` in + * particular — is what check:pack-boot asserts on the packed tarball, so pin the + * stamped contract here. + */ +test("a stamped local-CLI caller receives the full payload including version", async () => { + const pkgVersion = JSON.parse( + fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8") + ).version; + const res = await route.GET( + new Request("http://localhost/api/monitoring/health", { + headers: { + "x-omniroute-auth-kind": "management_key", + "x-omniroute-auth-label": "local-cli-token", + }, + }) as never + ); + const body = (await res.json()) as Record; + assert.equal(res.status, 200); + assert.equal(body.version, pkgVersion); +}); diff --git a/tests/unit/route-guard-private-lan.test.ts b/tests/unit/route-guard-private-lan.test.ts index a19bed6d5f..f70074c59f 100644 --- a/tests/unit/route-guard-private-lan.test.ts +++ b/tests/unit/route-guard-private-lan.test.ts @@ -95,17 +95,38 @@ test("management policy must NOT derive locality from the spoofable Host header" join(import.meta.dirname, "../../src/server/authz/policies/management.ts"), "utf8" ); + // `requestPeerAddress` and friends moved to authz/peerContext.ts when the PUBLIC + // policy had to share the very same verdict (check:pack-boot / #11040 follow-up). + // The guard follows the implementation instead of the filename: neither module may + // read the Host header, and the module that OWNS peer resolution must resolve the + // token-stamped peer IP. + const peerSrc = readFileSync( + join(import.meta.dirname, "../../src/server/authz/peerContext.ts"), + "utf8" + ); // Regression guard: a prior fix read the client-controlled Host header for the // LOCAL_ONLY decision, letting `Host: 127.0.0.1` bypass the gate. Locality must // come from the token-stamped peer IP instead. + for (const [name, text] of [ + ["management.ts", src], + ["peerContext.ts", peerSrc], + ] as const) { + assert.ok( + !text.includes('get?.("host")') && !text.includes('get("host")'), + `${name} must NOT read the Host header for locality` + ); + } assert.ok( - !src.includes('get?.("host")') && !src.includes('get("host")'), - "requestPeerAddress must NOT read the Host header" - ); - assert.ok( - src.includes("resolveStampedPeer") && src.includes("PEER_IP_HEADER"), + peerSrc.includes("resolveStampedPeer") && peerSrc.includes("PEER_IP_HEADER"), "requestPeerAddress must resolve the trusted token-stamped peer IP" ); + // Positive anchor: management.ts must still route its locality decision through + // the shared helpers, so this guard cannot pass by the policy quietly growing its + // own Host-based path again. + assert.ok( + src.includes("peerContext") && src.includes("isLoopbackRequest"), + "management policy must delegate locality to authz/peerContext" + ); }); // ── resolveStampedPeer: the auth boundary that replaces Host-header trust ── diff --git a/tests/unit/router-eval-cli.test.ts b/tests/unit/router-eval-cli.test.ts index a5c0332f7d..8e44a7fe59 100644 --- a/tests/unit/router-eval-cli.test.ts +++ b/tests/unit/router-eval-cli.test.ts @@ -8,7 +8,7 @@ // limitation, not a defect in the code under test: the OmniRoute runtime itself // cascades to node:sqlite/sql.js when better-sqlite3 is unavailable. See // tests/unit/_helpers/betterSqlite3Availability.ts for a guard helper. -import test from "node:test"; +import test, { after } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -18,9 +18,19 @@ import Database from "better-sqlite3"; const scriptPath = "scripts/router-eval/index.ts"; +// #10432 (guard #10428) made every process that detects a test context but has no +// explicit DATA_DIR warn on stderr before falling back to a throwaway dir. +// `NODE_TEST_CONTEXT` is inherited by the children spawned below, so the CLI printed +// that warning and broke the "stderr stays empty" assertions. Give every child its own +// DATA_DIR — the exact resolution the guard message prescribes — instead of loosening +// the assertions. +const cliDataDir = mkdtempSync(join(tmpdir(), "router-eval-cli-datadir-")); +after(() => rmSync(cliDataDir, { recursive: true, force: true })); + function runCli(args: string[]) { return spawnSync(process.execPath, ["--import", "tsx", scriptPath, ...args], { encoding: "utf8", + env: { ...process.env, DATA_DIR: cliDataDir }, }); } diff --git a/tests/unit/ui/CliAgentsPage.test.tsx b/tests/unit/ui/CliAgentsPage.test.tsx index 257f5e4149..fd1cb1a6d6 100644 --- a/tests/unit/ui/CliAgentsPage.test.tsx +++ b/tests/unit/ui/CliAgentsPage.test.tsx @@ -4,6 +4,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ToolBatchStatusMap } from "@/shared/types/cliBatchStatus"; +import { CLI_TOOLS } from "@/shared/constants/cliTools"; // ── Mocks (declared before any imports that depend on them) ─────────────────── @@ -48,9 +49,8 @@ vi.mock("@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge", () => // ── Static imports after mocks ──────────────────────────────────────────────── -const { default: CliAgentsPageClient } = await import( - "@/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient" -); +const { default: CliAgentsPageClient } = + await import("@/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient"); // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -59,16 +59,14 @@ const { default: CliAgentsPageClient } = await import( * 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; +// Derivado do catalogo, NAO escrito a mao. A lista hardcoded anterior ja tinha +// derivado duas vezes (6 -> 8 com omp/letta, depois 8 -> 9 com prime-agent), e a +// consequencia nao e obvia: um agente ausente daqui nao entra no mapa de status, +// cai no default not_installed e contamina os testes de filtro/contagem com um +// card extra. Derivar mantem o fixture em sincronia com a fonte por construcao. +const AGENT_IDS = Object.values(CLI_TOOLS) + .filter((tool) => tool.category === "agent") + .map((tool) => tool.id); function makeBatchStatusMap(overrides: Partial = {}): ToolBatchStatusMap { const base: ToolBatchStatusMap = {}; @@ -150,9 +148,9 @@ describe("CliAgentsPageClient", () => { expect(container.textContent).toContain("pageTitle"); }, 15000); - it("2. renders exactly 8 agent tool cards", async () => { + it("2. renders exactly one card per agent in the catalog", async () => { const container = await renderPage(); - expect(countAgentCards(container)).toBe(8); + expect(countAgentCards(container)).toBe(AGENT_IDS.length); }, 15000); it("3. search filter — 'hermes' shows 1 card (hermes-agent)", async () => { @@ -175,9 +173,7 @@ describe("CliAgentsPageClient", () => { const visibleCards = countAgentCards(container); expect(visibleCards).toBe(1); - const remainingHrefs = Array.from( - container.querySelectorAll("a[href]") - ) + const remainingHrefs = Array.from(container.querySelectorAll("a[href]")) .filter((a) => a.getAttribute("href")?.startsWith("/dashboard/cli-agents/")) .map((a) => a.getAttribute("href") ?? ""); diff --git a/tests/unit/ui/ProxyRegistryManager-credential-autofill.test.tsx b/tests/unit/ui/ProxyRegistryManager-credential-autofill.test.tsx index 760f14bbe1..cc203aa10e 100644 --- a/tests/unit/ui/ProxyRegistryManager-credential-autofill.test.tsx +++ b/tests/unit/ui/ProxyRegistryManager-credential-autofill.test.tsx @@ -9,6 +9,14 @@ vi.mock("next-intl", () => ({ useTranslations: () => translate, })); +// Imported STATICALLY on purpose. With a dynamic `await import()` inside each +// test body, the Vite transform of the component's dependency tree (~86s on a +// loaded box) was charged against the per-test timeout, so both guards timed +// out before asserting anything. At module scope that cost is paid during +// collection, which has no per-test budget. `vi.mock` above is hoisted by +// Vitest, so the next-intl stub is still in place for this import. +import ProxyRegistryManager from "@/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager"; + const SEEDED_PROXY = { id: "proxy-8855", name: "Seeded proxy", @@ -135,104 +143,110 @@ afterEach(() => { }); describe("ProxyRegistryManager credential autofill regression #8855", () => { - it("keeps Edit → close → Add credentials blank and isolates both fields from autofill", { timeout: 60000 }, async () => { - const { default: ProxyRegistryManager } = - await import("@/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager"); + // Explicit budgets: each flow chains several `waitFor` polls (2s each) plus + // React act() flushes, which overruns Vitest's 5s default on a busy box. + it( + "keeps Edit → close → Add credentials blank and isolates both fields from autofill", + { timeout: 60000 }, + async () => { + await act(async () => { + root.render(); + }); + await waitFor(() => expect(container.textContent).toContain(SEEDED_PROXY.name)); - await act(async () => { - root.render(); - }); - await waitFor(() => expect(container.textContent).toContain(SEEDED_PROXY.name)); + await click(findButton("edit")); + const editUsername = findCredentialInput("labelUsername"); + const editPassword = findCredentialInput("labelPassword"); + expect(editUsername.value).toBe(""); + expect(editPassword.value).toBe(""); - await click(findButton("edit")); - const editUsername = findCredentialInput("labelUsername"); - const editPassword = findCredentialInput("labelPassword"); - expect(editUsername.value).toBe(""); - expect(editPassword.value).toBe(""); + setInputValue(editUsername, "edit-user-sentinel"); + setInputValue(editPassword, "edit-password-sentinel"); + await click(container.querySelector('button[aria-label="close"]')!); + await click( + container.querySelector('[data-testid="proxy-registry-open-create"]')! + ); - setInputValue(editUsername, "edit-user-sentinel"); - setInputValue(editPassword, "edit-password-sentinel"); - await click(container.querySelector('button[aria-label="close"]')!); - await click( - container.querySelector('[data-testid="proxy-registry-open-create"]')! - ); + const createUsername = findCredentialInput("labelUsername"); + const createPassword = findCredentialInput("labelPassword"); + expect(createUsername.value).toBe(""); + expect(createPassword.value).toBe(""); - const createUsername = findCredentialInput("labelUsername"); - const createPassword = findCredentialInput("labelPassword"); - expect(createUsername.value).toBe(""); - expect(createPassword.value).toBe(""); + expect.soft(createUsername.getAttribute("autocomplete")).toBe("off"); + expect.soft(createPassword.getAttribute("autocomplete")).toBe("new-password"); + for (const input of [createUsername, createPassword]) { + expect.soft(input.getAttribute("data-1p-ignore")).toBe("true"); + expect.soft(input.getAttribute("data-lpignore")).toBe("true"); + } - expect.soft(createUsername.getAttribute("autocomplete")).toBe("off"); - expect.soft(createPassword.getAttribute("autocomplete")).toBe("new-password"); - for (const input of [createUsername, createPassword]) { - expect.soft(input.getAttribute("data-1p-ignore")).toBe("true"); - expect.soft(input.getAttribute("data-lpignore")).toBe("true"); + setInputValue( + container.querySelector('[data-testid="proxy-registry-name-input"]')!, + "New proxy" + ); + setInputValue( + container.querySelector('[data-testid="proxy-registry-host-input"]')!, + "proxy.example.test" + ); + await click(findButton("save")); + await waitFor(() => expect(postBody).toBeDefined()); + + expect([undefined, ""]).toContain(postBody?.username); + expect([undefined, ""]).toContain(postBody?.password); + expect(postBody?.username).not.toBe("edit-user-sentinel"); + expect(postBody?.password).not.toBe("edit-password-sentinel"); } + ); - setInputValue( - container.querySelector('[data-testid="proxy-registry-name-input"]')!, - "New proxy" - ); - setInputValue( - container.querySelector('[data-testid="proxy-registry-host-input"]')!, - "proxy.example.test" - ); - await click(findButton("save")); - await waitFor(() => expect(postBody).toBeDefined()); + it( + "round-trips dead status through Edit and excludes it from pool candidates", + { timeout: 60000 }, + async () => { + responseItems = [DEAD_PROXY]; - expect([undefined, ""]).toContain(postBody?.username); - expect([undefined, ""]).toContain(postBody?.password); - expect(postBody?.username).not.toBe("edit-user-sentinel"); - expect(postBody?.password).not.toBe("edit-password-sentinel"); - }); + await act(async () => { + root.render(); + }); + await waitFor(() => expect(container.textContent).toContain(DEAD_PROXY.name)); - it("round-trips dead status through Edit and excludes it from pool candidates", async () => { - responseItems = [DEAD_PROXY]; + await click(findButton("edit")); + const statusSelect = container.querySelector( + '[data-testid="proxy-registry-status-select"]' + ); + expect(statusSelect).not.toBeNull(); + expect(statusSelect?.value).toBe("dead"); + expect(statusSelect?.querySelector('option[value="dead"]')).not.toBeNull(); - const { default: ProxyRegistryManager } = - await import("@/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager"); + await click(findButton("save")); + await waitFor(() => expect(patchBody).toBeDefined()); + expect(patchBody).toMatchObject({ id: DEAD_PROXY.id, status: "dead" }); - await act(async () => { - root.render(); - }); - await waitFor(() => expect(container.textContent).toContain(DEAD_PROXY.name)); + await click(findButton("managePool")); + const scopeSelect = container.querySelector( + '[data-testid="proxy-registry-pool-scope"]' + ); + expect(scopeSelect).not.toBeNull(); + const setter = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, + "value" + )?.set; + if (!setter || !scopeSelect) throw new Error("Pool scope select is unavailable"); + act(() => { + setter.call(scopeSelect, "global"); + scopeSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + await click( + container.querySelector('[data-testid="proxy-registry-pool-load"]')! + ); + await waitFor(() => + expect( + container.querySelector('[data-testid="proxy-registry-pool-add-select"]') + ).not.toBeNull() + ); - await click(findButton("edit")); - const statusSelect = container.querySelector( - '[data-testid="proxy-registry-status-select"]' - ); - expect(statusSelect).not.toBeNull(); - expect(statusSelect?.value).toBe("dead"); - expect(statusSelect?.querySelector('option[value="dead"]')).not.toBeNull(); - - await click(findButton("save")); - await waitFor(() => expect(patchBody).toBeDefined()); - expect(patchBody).toMatchObject({ id: DEAD_PROXY.id, status: "dead" }); - - await click(findButton("managePool")); - const scopeSelect = container.querySelector( - '[data-testid="proxy-registry-pool-scope"]' - ); - expect(scopeSelect).not.toBeNull(); - const setter = Object.getOwnPropertyDescriptor( - window.HTMLSelectElement.prototype, - "value" - )?.set; - if (!setter || !scopeSelect) throw new Error("Pool scope select is unavailable"); - act(() => { - setter.call(scopeSelect, "global"); - scopeSelect.dispatchEvent(new Event("change", { bubbles: true })); - }); - await click(container.querySelector( - '[data-testid="proxy-registry-pool-load"]' - )!); - await waitFor(() => - expect(container.querySelector('[data-testid="proxy-registry-pool-add-select"]')).not.toBeNull() - ); - - const poolAddSelect = container.querySelector( - '[data-testid="proxy-registry-pool-add-select"]' - ); - expect(poolAddSelect?.querySelector(`option[value="${DEAD_PROXY.id}"]`)).toBeNull(); - }); + const poolAddSelect = container.querySelector( + '[data-testid="proxy-registry-pool-add-select"]' + ); + expect(poolAddSelect?.querySelector(`option[value="${DEAD_PROXY.id}"]`)).toBeNull(); + } + ); }); diff --git a/tests/unit/ui/ProxyRegistryManager-tdz-render.test.tsx b/tests/unit/ui/ProxyRegistryManager-tdz-render.test.tsx index f4e2b246cb..1879466fb6 100644 --- a/tests/unit/ui/ProxyRegistryManager-tdz-render.test.tsx +++ b/tests/unit/ui/ProxyRegistryManager-tdz-render.test.tsx @@ -16,10 +16,17 @@ vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, })); +// The component is imported STATICALLY on purpose. Pulling it in with a dynamic +// `await import()` inside the test body charged the whole Vite transform of its +// dependency tree (measured at ~86s on a loaded box) against the per-test +// timeout, so the guard timed out instead of asserting anything. At module +// scope that cost is paid during collection, which has no per-test budget. +// The regression itself is unaffected: the #5918 ReferenceError is thrown while +// the component body RENDERS, not while the module is evaluated. +import ProxyRegistryManager from "@/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager"; + describe("ProxyRegistryManager (TDZ regression #5918)", () => { - it("server-renders without a use-before-init ReferenceError", { timeout: 30000 }, async () => { - const { default: ProxyRegistryManager } = - await import("@/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager"); + it("server-renders without a use-before-init ReferenceError", () => { const html = renderToString(React.createElement(ProxyRegistryManager)); // The heading key is rendered via the mocked translator (key echo). expect(html).toContain("title"); diff --git a/tests/unit/ui/connectionsSearchFilter.test.tsx b/tests/unit/ui/connectionsSearchFilter.test.tsx index 07d4c5a099..6885c1a91c 100644 --- a/tests/unit/ui/connectionsSearchFilter.test.tsx +++ b/tests/unit/ui/connectionsSearchFilter.test.tsx @@ -19,7 +19,7 @@ import React, { act, useEffect } from "react"; import { createRoot } from "react-dom/client"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; import { matchesAccountQuery, filterConnectionsByQuery, @@ -116,6 +116,18 @@ describe("useProviderConnections — accountSearch (#7937)", () => { let container: HTMLElement; let root: ReturnType; + // The hook pulls in a large Next/dashboard module graph; letting each `it()` + // do the `await import()` bills Vite's first-time transform of that graph to + // the 5s per-test budget and made the first test time out. Loading it once in + // beforeAll (with its own generous hook budget) keeps the per-test budget for + // the behaviour under test — no assertion is relaxed. + let useProviderConnections: (typeof import("@/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections"))["useProviderConnections"]; + + beforeAll(async () => { + ({ useProviderConnections } = + await import("@/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections")); + }, 60_000); + beforeEach(() => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -134,10 +146,6 @@ describe("useProviderConnections — accountSearch (#7937)", () => { }); it("defaults accountSearch to empty string and exposes setAccountSearch", async () => { - const { useProviderConnections } = await import( - "@/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections" - ); - type HookResult = ReturnType; let result: HookResult | null = null; @@ -158,10 +166,6 @@ describe("useProviderConnections — accountSearch (#7937)", () => { }); it("resets page to 0 when the search query changes", async () => { - const { useProviderConnections } = await import( - "@/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections" - ); - type HookResult = ReturnType; let result: HookResult | null = null; diff --git a/tests/unit/ui/highlightableProviderCard.test.tsx b/tests/unit/ui/highlightableProviderCard.test.tsx index cefafdce64..22abb02782 100644 --- a/tests/unit/ui/highlightableProviderCard.test.tsx +++ b/tests/unit/ui/highlightableProviderCard.test.tsx @@ -18,6 +18,7 @@ import HighlightableProviderCard from "@/app/(dashboard)/dashboard/providers/com vi.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })); vi.mock("@/shared/components/ProviderTestSlideOver", () => ({ default: () => null })); vi.mock("@/shared/components/ProviderIcon", () => ({ default: () => null })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: () => {} }) })); // Deterministic anchor so the click path does not depend on next/link's router. vi.mock("next/link", () => ({ __esModule: true, diff --git a/tests/unit/ui/providerCardHandle.test.tsx b/tests/unit/ui/providerCardHandle.test.tsx index ce8f4b7fd7..bd4860b13e 100644 --- a/tests/unit/ui/providerCardHandle.test.tsx +++ b/tests/unit/ui/providerCardHandle.test.tsx @@ -16,6 +16,7 @@ import ProviderCard, { vi.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })); vi.mock("@/shared/components/ProviderTestSlideOver", () => ({ default: () => null })); vi.mock("@/shared/components/ProviderIcon", () => ({ default: () => null })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: () => {} }) })); // jsdom does not implement scrollIntoView or animate if (typeof Element.prototype.scrollIntoView === "undefined") { diff --git a/tests/unit/ui/providerCardKimiPartnerAccent.test.tsx b/tests/unit/ui/providerCardKimiPartnerAccent.test.tsx index 07aceb699a..85fa7e7b30 100644 --- a/tests/unit/ui/providerCardKimiPartnerAccent.test.tsx +++ b/tests/unit/ui/providerCardKimiPartnerAccent.test.tsx @@ -26,6 +26,7 @@ import ProviderCard from "@/app/(dashboard)/dashboard/providers/components/Provi vi.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })); vi.mock("@/shared/components/ProviderTestSlideOver", () => ({ default: () => null })); vi.mock("@/shared/components/ProviderIcon", () => ({ default: () => null })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: () => {} }) })); describe("ProviderCard — Kimi (Moonshot AI) founding-friend accent", () => { let container: HTMLDivElement | null = null; diff --git a/tests/unit/ui/use-provider-models-auto-fetch.test.tsx b/tests/unit/ui/use-provider-models-auto-fetch.test.tsx index b415339f10..30f5ad1d75 100644 --- a/tests/unit/ui/use-provider-models-auto-fetch.test.tsx +++ b/tests/unit/ui/use-provider-models-auto-fetch.test.tsx @@ -75,11 +75,21 @@ describe("useProviderModels upstream auto-fetch", () => { const mounted = await renderProviderModels(); await flushQueuedSync(); + // Desmontar ANTES de asserir. O hook agenda a auto-sync num setTimeout e o + // callback so checa o flag `cancelled` na entrada; enquanto o componente + // estiver montado esse flag e false. Se o timer escapar da janela do teste, + // ele dispara depois do afterEach ja ter feito unstubAllGlobals() e cai no + // fetch REAL com uma URL relativa — `new URL` estoura e derruba o arquivo. + // Isso nao acontece com a maquina ociosa, so sob os 20 workers da suite + // cheia, e foi assim que este teste virou vermelho intermitente no CI. + // Desmontar primeiro faz `cancelled` virar true e o callback sair cedo; as + // chamadas ja registradas no fetchMock continuam disponiveis para o assert. + mounted.unmount(); + expect(fetchMock).not.toHaveBeenCalledWith( "/api/providers/connection-1/sync-models?mode=sync", expect.anything() ); - mounted.unmount(); }); it("synchronizes upstream models only when autoFetchModels is explicitly true", async () => { @@ -177,5 +187,9 @@ describe("useProviderModels upstream auto-fetch", () => { { method: "POST" } ); mounted.unmount(); + + expect(fetchMock).toHaveBeenCalledWith("/api/providers/connection-1/sync-models?mode=sync", { + method: "POST", + }); }); }); diff --git a/tests/unit/volcengine-cookie-domain-suffix.test.ts b/tests/unit/volcengine-cookie-domain-suffix.test.ts new file mode 100644 index 0000000000..bb55ee1fca --- /dev/null +++ b/tests/unit/volcengine-cookie-domain-suffix.test.ts @@ -0,0 +1,69 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { isVolcengineCookieDomain } from "../../open-sse/services/volcengineConsoleAutoLogin.ts"; + +// CodeQL js/incomplete-url-substring-sanitization (#860, #861). The console +// auto-login harvested `digest`/`AccountID`/`csrfToken`/`userInfo` from any +// cookie whose domain merely *contained* "volcengine.com", so a cookie set by +// `volcengine.com.attacker.tld` (or `notvolcengine.com`) was accepted as an +// operator credential and persisted as a provider connection. Match the domain +// the way a cookie domain has to be matched: exact host or a dot-boundary +// suffix. Mirrors isAdobeCookieDomain in adobeFireflyBrowserLogin.ts. + +test("accepts the real console cookie domains", () => { + for (const domain of [ + "volcengine.com", + ".volcengine.com", + "console.volcengine.com", + ".console.volcengine.com", + "CONSOLE.VOLCENGINE.COM", + " .volcengine.com ", + ]) { + assert.equal(isVolcengineCookieDomain(domain), true, domain); + } +}); + +test("rejects look-alike domains that merely contain the string", () => { + for (const domain of [ + "volcengine.com.attacker.tld", + ".volcengine.com.evil.example", + "notvolcengine.com", + "myvolcengine.com", + "volcengine.com.br", + "evil.tld/volcengine.com", + "volcengine.company", + ]) { + assert.equal(isVolcengineCookieDomain(domain), false, domain); + } +}); + +test("rejects empty / missing domains instead of throwing", () => { + assert.equal(isVolcengineCookieDomain(undefined), false); + assert.equal(isVolcengineCookieDomain(""), false); + assert.equal(isVolcengineCookieDomain(" "), false); +}); + +// The same class exists in inAppLoginService's cookie capture, where the +// expected domain comes from TOKEN_EXTRACTION_CONFIGS instead of a literal — +// which is why CodeQL did not flag it. Same helper, same guarantees. + +test("matchesCookieDomain handles a config-supplied expected domain", async () => { + const { matchesCookieDomain } = await import("../../open-sse/utils/cookieDomain.ts"); + + assert.equal(matchesCookieDomain("app.example.com", "example.com"), true); + assert.equal(matchesCookieDomain(".example.com", ".example.com"), true); + assert.equal(matchesCookieDomain("example.com", ".example.com"), true); + + assert.equal(matchesCookieDomain("example.com.attacker.tld", "example.com"), false); + assert.equal(matchesCookieDomain("notexample.com", "example.com"), false); + assert.equal(matchesCookieDomain("example.com", "app.example.com"), false); +}); + +test("matchesCookieDomain fails closed on a missing expected domain", async () => { + const { matchesCookieDomain } = await import("../../open-sse/utils/cookieDomain.ts"); + + assert.equal(matchesCookieDomain("example.com", undefined), false); + assert.equal(matchesCookieDomain("example.com", ""), false); + assert.equal(matchesCookieDomain("example.com", "."), false); +}); diff --git a/tests/unit/volcengine-plan-connect-validation.test.ts b/tests/unit/volcengine-plan-connect-validation.test.ts new file mode 100644 index 0000000000..239576f03c --- /dev/null +++ b/tests/unit/volcengine-plan-connect-validation.test.ts @@ -0,0 +1,118 @@ +/** + * tests/unit/volcengine-plan-connect-validation.test.ts + * + * Hard Rule #7 (Zod on every input) for the volcengine-plan connect routes. + * + * These three routes read `await request.json()` and then hand the raw fields + * to the auto-login service after ad-hoc `typeof` checks. The t06 + * route-validation gate flags exactly that shape, and the gap is real: an + * unvalidated body reaches a service that drives a headless browser login. + * + * The two session routes are the fast, deterministic probes: today an invalid + * body reaches the session lookup and comes back 404 (or coerces silently — + * `String(body.code ?? "")` turns 123 into "123"); after the fix the body is + * rejected with 400 BEFORE the session is ever looked up. + * + * DATA_DIR is redirected to a temp dir BEFORE the route imports, since the + * auth pipeline touches the DB singleton at import time. With a fresh DB no + * password is set, so requireManagementAuth() lets the request through and + * the assertions actually reach the body-validation branch. + */ +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"; + +process.env.NODE_ENV = "test"; +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-volc-connect-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-volc-connect-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const codeRoute = await import( + "../../src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts" +); +const identityRoute = await import( + "../../src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts" +); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function post(body: unknown): Request { + return new Request("http://localhost/api/providers/volcengine-plan/connect/sess-unknown/code", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const params = Promise.resolve({ sessionId: "sess-unknown-for-validation-test" }); + +// ── code route ─────────────────────────────────────────────────────────────── + +test("code route rejects a non-string code with 400 instead of coercing it", async () => { + // Before the fix: `String(body.code ?? "")` happily turns 123 into "123" and + // the route answers 404 (unknown session) — the bad type never surfaces. + const res = await codeRoute.POST(post({ code: 123 }), { params }); + assert.equal(res.status, 400); + const body = (await res.json()) as { error?: string }; + assert.match(String(body.error), /invalid|code/i); +}); + +test("code route rejects a missing code with 400", async () => { + const res = await codeRoute.POST(post({ captcha: "abcd" }), { params }); + assert.equal(res.status, 400); +}); + +test("code route rejects a non-string captcha with 400", async () => { + const res = await codeRoute.POST(post({ code: "123456", captcha: 99 }), { params }); + assert.equal(res.status, 400); +}); + +test("code route validates the body BEFORE the session lookup", async () => { + // The session id is unknown, so an unvalidated route answers 404. A validated + // one must answer 400: the body is refused before any session state is read. + const res = await codeRoute.POST(post({ code: 123 }), { params }); + assert.notEqual(res.status, 404); + assert.equal(res.status, 400); +}); + +// ── identity route ─────────────────────────────────────────────────────────── + +test("identity route rejects a non-integer index with 400 before the session lookup", async () => { + const res = await identityRoute.POST(post({ index: "not-a-number" }), { params }); + assert.equal(res.status, 400); + assert.notEqual(res.status, 404); +}); + +test("identity route rejects a negative index with 400", async () => { + const res = await identityRoute.POST(post({ index: -1 }), { params }); + assert.equal(res.status, 400); +}); + +test("identity route rejects a non-numeric timeout with 400", async () => { + const res = await identityRoute.POST(post({ index: 0, timeout: "soon" }), { params }); + assert.equal(res.status, 400); +}); + +// ── gate contract ──────────────────────────────────────────────────────────── + +test("all three connect routes parse their body through a Zod schema (t06 gate)", () => { + const ROOT = path.join(import.meta.dirname, "..", ".."); + const files = [ + "src/app/api/providers/volcengine-plan/connect/route.ts", + "src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts", + "src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts", + ]; + for (const rel of files) { + const src = fs.readFileSync(path.join(ROOT, rel), "utf8"); + assert.ok( + src.includes("validateBody(") || src.includes(".safeParse("), + `${rel} must validate its body with Zod (check:route-validation:t06)` + ); + } +});