Compare commits

..

4 Commits

Author SHA1 Message Date
Xiangzhe
f45abff45a docs: correct drifted counts, reconcile the v3.8.50 changelog and gate the free-forever number
Audit of every numeric claim in the README, AGENTS.md and the README SVGs against
live code, plus a changelog/credit reconciliation over the full v3.8.50 cycle.

Corrected numbers (all measured, not estimated):

- Provider circuit breaker thresholds were scaled up in code for 500+ connection
  deployments (`providerFailureThreshold`: OAuth 3 -> 10, API key 5 -> 15) but the
  docs still published the pre-scale values. Fixed in AGENTS.md (now a table that
  also separates the provider-level threshold from the per-connection one and lists
  the provider cooldowns), in the README alt text and inside resilience-layers.svg
  (visible label and aria-label).
- "40+ free forever" was unsourced. Measured from the free-tier catalog as every
  provider whose free access renews or needs no key (recurring-monthly, -daily,
  -uncapped, -credit, keyless; one-time signup credits and discontinued pools
  excluded): 56. Updated in the README and promise-pillars.svg.
- Cycle-evolution table: v3.8.49 shipped 290 providers, not 291, and the model row
  compared the v3.8.49 free-tier catalog (516) against today's full catalog. Both
  columns now use the same metric - distinct documented models, 1185 -> 1202.
- Tech-stack row: 95 domain modules -> 117.

The free-forever count is now enforced by check:docs-counts so it cannot drift
again; it is derived from freeType in the live catalog, like every other gated
number.

Changelog reconciliation (`scripts/release/list-uncovered-commits.mjs`):
uncovered cycle commits drop from 149 to 62. 75 user-facing commits gained a bullet
with author attribution, the 45 ref-less direct pushes and 29 chore/ci/test/docs
commits were consolidated into rollup bullets, and the contributors table grew from
147 to 161 rows - 14 contributors who had landed work with no credit at all
(including @amartinawi, @pacocartones and @excessivechaos) are now credited.
The remaining 62 carry no PR/issue ref, which is the ceiling of ref-based coverage.
2026-08-14 21:29:20 -03:00
Diego Rodrigues de Sa e Souza
e05ac345da feat(sse): honor provider-rule lock scope for agentrouter (connection vs model) (#10419)
Makes the ProviderErrorRule `scope` field real at the persistence layer, exclusively for agentrouter (owner decision; every other provider keeps byte-identical behavior).

checkFallbackError now surfaces `ruleScope` behind the HONORS_RULE_LOCK_SCOPE_PROVIDERS allowlist, and the agentrouter 403 path consults the rules before the generic apikey-FORBIDDEN early-return. markAccountUnavailable honors scope "connection" with a temporary connection cooldown instead of a per-model lockout — guarded so a permanent state can never be downgraded to a transient retry loop — and combo now skips the exhausted account within the same request, which also stops force-reusing the just-cooled connection via allowRateLimitedConnection.

Documented in RESILIENCE_GUIDE §7 with the honest limits (disableCooling connections keep per-model behavior; the 6h model-access cooldown is clamped by mlSettings.maxCooldownMs, 30min by default; same-request skip needs targets carrying their own connectionId).

Closes #10334
2026-08-14 20:52:53 -03:00
Diego Rodrigues de Sa e Souza
7bb3bc7e32 fix(ci): pin Build (advisory) to a hosted runner with memory provisioning (#10408)
* fix(ci): pin Build (advisory) to a hosted runner with memory provisioning

`Build (advisory)` has been reporting a permanent red on every PR while
producing no usable signal at all.

Measured over the last 25 quality.yml runs (2026-08-14): not one instance of
the job reached a conclusion. Every sample was either queued on the
self-hosted pool — 2 runners, omniroute-113-6/7, both permanently busy; one
job sat queued for over 2 hours and was still unclaimed — or, when it did land
on a runner, killed mid-build by this workflow's own cancel-in-progress
concurrency. All 6 sampled "failures" are exit 143 / "The runner has received
a shutdown signal" at ~3.5 min into `npm run build`. Zero OOM, zero build
errors. The job was consuming a runner the real gates compete for while
telling every PR author it was broken.

Gap 19 deliberately left USE_VPS_RUNNER governing build-like jobs, on the
premise that the build needs the .113's RAM. That premise no longer holds:
`Fast Production Build` (build.yml) runs `build:release` — a superset of this
job's `npm run build`, plus the CLI bundle — on plain ubuntu-latest and passed
24 of its last 25 runs in ~15 min. The difference is memory PROVISIONING, not
the machine: a 10 GB swapfile plus a 12 GB V8 heap. Swap is the part that
matters, because --max-old-space-size bounds only V8's JS heap and never
Turbopack's native Rust allocation (#6409).

Pins the job to ubuntu-latest and mirrors both settings from build.yml.
USE_VPS_RUNNER keeps its other consumers (ci.yml Build, nightly-release-green,
npm-publish), so the variable stays meaningful. Fork safety is strictly
improved: no PR can reach the LAN runner through this job any more.

check:workflows --ratchet: 186 zizmor findings, baseline 190, no regression.
prettier + YAML parse: clean.

* fix(ci): scope Build (advisory) to fork PRs

Follow-up to the hosted-runner pin in this same PR, after measuring what the
job is actually for.

build.yml's `Fast Production Build` triggers on `push: branches: ["**"]` and
runs `build:release` — a superset of this job's `npm run build`, plus the CLI
bundle. For an own-origin branch that push fires here, so the tree was being
built twice per PR. A fork contributor pushes to THEIR repo, so build.yml
never runs in this repo and this job is their only pre-merge build signal.

That could have argued for deleting the job, except the traffic says
otherwise: 72 of the last 100 PRs into release/** come from forks. The fork
case is the majority, not the exception. So the job earns its place — it just
should not duplicate build.yml for the own-origin 28%. Added the fork filter
to the existing `if`.

Also corrects the reliability claim in the previous commit message. Over a
wider window the job is not literally never-green: across 2026-08-13/14 it
reached `success` on roughly 10-15% of runs (13/138 on 08-14, 7/53 sampled on
08-13). Chronically unreliable, not permanently dead — the conclusion and the
fix are unchanged.

The #7307 guard in tests/unit/build/check-workflows.test.ts pinned the old
self-hosted expression, so it is realigned here: it now asserts the hosted
pin, the absence of self-hosted/USE_VPS_RUNNER in the job's DIRECTIVES (the
comment legitimately explains why the pool was abandoned, so the scan strips
comments), both memory settings, and the fork filter. Mutation-validated —
restoring self-hosted, dropping the swapfile, or flipping the fork filter each
turns it red.

check-workflows.test.ts: 32 pass, 0 fail.
check:workflows --ratchet: 186 findings, baseline 190, no regression.

---------

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-14 17:27:45 -03:00
Diego Rodrigues de Sa e Souza
8d1a59771a fix(providers): refresh the translate-path golden for the bailian Token Plan endpoint (#10410)
#10290 moved bailian-coding-plan from the Coding Plan host to the documented
Token Plan one, but the provider/translate-path golden still pinned
coding-intl.dashscope.aliyuncs.com, so tests/unit/provider-translate-path-golden.test.ts
fails on the release tip.

Regenerates the snapshot (UPDATE_GOLDEN=1) — the diff is exactly the two
bailian-coding-plan URLs, every other provider byte-identical — and fixes the
same stale host in the endpoint matrix of
docs/providers/ALIBABA-QWEN-PROVIDER-FAMILIES.md.

This golden covers every provider's resolved URL, which is why neither the
focused tests nor typecheck caught the change: only the unit shard runs it.

Refs #9603

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-14 16:52:00 -03:00
21 changed files with 1603 additions and 618 deletions

View File

@@ -60,13 +60,49 @@ jobs:
build:
name: Build (advisory)
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner — same fork-safe rule as ci.yml / fast-gates.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
# FORK PRs ONLY. build.yml's `Fast Production Build` triggers on `push: branches: ["**"]`
# and runs `build:release` — a superset of this job — so for an own-origin branch this job
# was building the same tree twice. A fork contributor pushes to THEIR repo, so that push
# never fires here, and this is the only pre-merge build signal they get. Measured
# 2026-08-14: 72 of the last 100 PRs into release/** came from forks, so the fork case is
# the majority of the traffic, not the exception — this job earns its place, it just should
# not duplicate build.yml for the own-origin 28%.
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true' && github.event.pull_request.head.repo.full_name != github.repository) }}
# PINNED to hosted — this was the last job in THIS workflow still on the USE_VPS_RUNNER
# switch (ci.yml's Build, nightly-release-green and npm-publish keep it, so the variable
# stays meaningful), and with USE_VPS_RUNNER=true it produced NO signal at all here.
# Measured 2026-08-14 over the last 25
# quality.yml runs: not one Build (advisory) reached a conclusion. Every sample was either
# queued on the self-hosted pool (2 runners, `omniroute-113-6/7`, both permanently busy — one
# job sat queued 2h+ and was still unclaimed) or, when it did land, killed mid-build by this
# workflow's own `cancel-in-progress` concurrency. 6/6 sampled "failures" are exit 143 /
# "The runner has received a shutdown signal" at ~3.5 min into `npm run build` — zero OOM,
# zero build errors. So the job burned a scarce runner that the gates actually need while
# reporting a permanent red on every PR.
#
# Gap 19 left USE_VPS_RUNNER governing build-like jobs on the premise that "the build needs
# the .113's RAM". That premise no longer holds: `Fast Production Build` (build.yml) runs
# `build:release` — a SUPERSET of this job's `npm run build`, plus the CLI bundle — on plain
# ubuntu-latest and passed 24/25 of its last runs in ~15 min. What it has and this job did
# not is memory PROVISIONING: a 10 GB swapfile plus a 12 GB V8 heap. That matters because
# --max-old-space-size only bounds V8's JS heap, never Turbopack's native (Rust) allocation
# (#6409) — swap is what absorbs the native peak. Both are mirrored below.
runs-on: ubuntu-latest
# #7307: advisory for the first week of release-PR runs; remove
# continue-on-error after the production-build signal is stable.
continue-on-error: true
steps:
# Mirrors build.yml: Turbopack's native peak is not bounded by --max-old-space-size, so
# the hosted runner needs swap headroom before the build starts.
- name: Expand virtual memory (10 GB swap)
run: |
sudo swapoff -a || true
sudo rm -f /mnt/swapfile /swapfile
sudo fallocate -l 10G /mnt/swapfile || sudo dd if=/dev/zero of=/mnt/swapfile bs=1M count=10240
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
free -h
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
@@ -79,6 +115,10 @@ jobs:
- run: npm run build
env:
OMNIROUTE_USE_TURBOPACK: "1"
# Same heap build.yml proves sufficient. build-next-isolated.mjs defaults to 8192 and
# honours OMNIROUTE_BUILD_MEMORY_MB; NODE_OPTIONS is set for parity with build.yml.
NODE_OPTIONS: "--max-old-space-size=12288"
OMNIROUTE_BUILD_MEMORY_MB: "12288"
# No artifact upload here: the PR-to-release quality workflow has no
# downstream package/e2e jobs that consume the Next.js build output.

View File

@@ -118,11 +118,18 @@ upstream/service level, so one unhealthy provider does not slow down every reque
- `HALF_OPEN`: reset timeout has elapsed; allow a probe request. Success closes the
breaker, failure opens it again.
**Defaults** (`open-sse/config/constants.ts`):
**Defaults** (`open-sse/config/constants.ts``PROVIDER_PROFILES`). Two thresholds live side by
side — do not confuse them:
- OAuth providers: threshold `3`, reset timeout `60s`.
- API-key providers: threshold `5`, reset timeout `30s`.
- Local providers: threshold `2`, reset timeout `15s`.
| Profile | `providerFailureThreshold` (whole provider) | `providerCooldownMs` | `circuitBreakerThreshold` (one connection) | `circuitBreakerReset` |
| ------- | ------------------------------------------: | -------------------: | -----------------------------------------: | --------------------: |
| OAuth | `10` | `5min` | `8` | `60s` |
| API key | `15` | `10min` | `12` | `30s` |
| Local | `2` | `1min` | `2` | `15s` |
The provider-level thresholds were scaled up for deployments with 500+ connections (OAuth was
`3`, API key was `5`); every default is overridable through the `OMNIROUTE_PROVIDER_BREAKER_*`
and `OMNIROUTE_CIRCUIT_BREAKER_*` env vars.
Only provider-level failure statuses should trip the provider breaker:
@@ -679,7 +686,7 @@ the stale-enforcement added in Fase 6A.3.
causa-raiz de DOIS wipes (2026-08-08 e 2026-08-10: `git reset --hard` materializou o
symlink rastreado por cima do diretório real e o git apagou todo o conteúdo ignorado sem
aviso); (c) após qualquer escrita relevante, `git -C _tasks add -A && git -C _tasks commit
&& git -C _tasks push` — o push frequente é o backup real; (d) repetir esta proibição
&& git -C _tasks push` — o push frequente é o backup real; (d) repetir esta proibição
VERBATIM no prompt de todo subagente que toque git; (e) se `_tasks` aparecer como symlink
quebrado, NÃO commitar nada — restaurar do remote e avisar o operador. O gate
`check:tracked-artifacts` (pre-commit + CI) bloqueia `_tasks` rastreado em qualquer forma.

View File

@@ -9,6 +9,7 @@
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._
### ✨ New Features
- **feat(core):** add Layer A capability filter at router (#5696)
- **feat(providers):** add DeepAI as paid API-key image provider ([#6671](https://github.com/diegosouzapw/OmniRoute/issues/6671))
- **feat(providers):** add Naga.ac and ChatAnywhere aggregator gateway providers (#6674 — thanks @chirag127)
@@ -31,7 +32,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
- **Providers**: expands the Novita AI catalog from a single Llama 3.1 8B entry to 19 curated serving models (DeepSeek V4, Kimi K3, GLM 5.2, MiniMax M3, Qwen3.7 Max, Qwen3 Coder 480B, MiMo V2.5 Pro, gpt-oss-120b, Gemma 4 31B and more), each carrying its real context window, output cap and reasoning flag from the live `/openai/v1/models` listing, and each vision flag confirmed by an actual image request rather than the listing's self-reported modalities ([#8913](https://github.com/diegosouzapw/OmniRoute/pull/8913)) — thanks @jax-novita
- **feat(providers):** native xAI Agent Tools passthrough on `/v1/responses` for `xai` / `xai-oauth` (`xao`) — forward `web_search` + `x_search` to `api.x.ai` instead of rewriting or rejecting them ([#8964](https://github.com/diegosouzapw/OmniRoute/issues/8964))
- **feat(providers): add UnoRouter provider** — UnoRouter is an OpenAI-compatible routing gateway supporting hundreds of models. It is now registered as an API-key provider. ([#8978](https://github.com/diegosouzapw/OmniRoute/issues/8978))
- **feat(sse):** deprecated the legacy `gemini-cli` **upstream provider**. It was not routable (no registry entry, no executor), yet the scheduler kept refreshing its token against Google — maintaining a credential that could never serve a request. A stored connection now becomes terminal with a legible reason and a working migration path: re-add the account under `gemini`, which uses the same Google OAuth client. The `gemini-cli` **client identity** (requests arriving *from* the Gemini CLI, issue #7034) is untouched ([#8980](https://github.com/diegosouzapw/OmniRoute/pull/8980))
- **feat(sse):** deprecated the legacy `gemini-cli` **upstream provider**. It was not routable (no registry entry, no executor), yet the scheduler kept refreshing its token against Google — maintaining a credential that could never serve a request. A stored connection now becomes terminal with a legible reason and a working migration path: re-add the account under `gemini`, which uses the same Google OAuth client. The `gemini-cli` **client identity** (requests arriving _from_ the Gemini CLI, issue #7034) is untouched ([#8980](https://github.com/diegosouzapw/OmniRoute/pull/8980))
- Add a default-off connection setting for Codex, OpenAI, and OpenAI-compatible Responses API providers that preserves client-supplied `reasoning.encrypted_content` items for replay, including per-target combo routing.
- Omit opaque encrypted reasoning values from persisted call logs while retaining compact diagnostic markers. (#9000)
- **feat(providers):** add Regolo AI OpenAI-compatible provider ([#9031](https://github.com/diegosouzapw/OmniRoute/issues/9031))
@@ -44,6 +45,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
- feat(images): execute full combo strategy + fallback in /v1/images/generations (#9239)
Adds open-sse/services/imageCombo.ts that expands combo targets, filters to images-capable, executes the priority strategy with handleImageGeneration per target, and returns the first success or last failure. Route patches detect combo names before model resolution and divert to the new execution path.
- feat: make forwarded upstream response-header budget configurable via env var (#9243)
- **feat(providers):** filter provider detail connections server-side while preserving full-page search and pagination. (thanks @RobertsXML) ([#9247](https://github.com/diegosouzapw/OmniRoute/pull/9247))
- **feat(providers):** make video_url passthrough configurable per provider/model via compat override ([#9248](https://github.com/diegosouzapw/OmniRoute/issues/9248)) — thanks @HellFiveOsborn
@@ -61,6 +63,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
- feat(opencode-plugin): warm catalog startup from disk snapshot + parallel refresh (#9490)
The config-shim hook now reads the last disk snapshot before fetching, so the provider registers immediately with the last-known-good catalog (~1-2s vs ~30s on a warm gateway). All six fetchers run concurrently via Promise.allSettled instead of sequentially. A failed refresh keeps the snapshot (no overwrite). An in-flight guard prevents concurrent refreshes for the same cache key. The features.diskCache: false opt-out disables the warm read entirely.
- **feat(models):** Test All's "Auto-hide failed models" no longer hides quota errors — daily-quota-exhausted and credits-exhausted responses are now classified via the routing path's existing quota detectors, so an evening Test All on a free-tier provider no longer silently wipes the catalog. Quota results stay visible with a distinct amber badge ([#9511](https://github.com/diegosouzapw/OmniRoute/issues/9511))
- Add an advisory forgotten-sibling-tests report to pull-request quality checks. The report traces changed modules through their static consumers to candidate sibling tests, while keeping barrel and dynamic-import cases non-blocking and requiring reviewed, referenced exceptions. (#9530)
- feat(providers): add Muse Code CLI provider preset (#9544)
@@ -76,6 +79,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
`model`, `provider`, `errorCode`.
Non-breaking — existing `onResponse` hooks with `{ streamed: true }` remain unchanged.
- **feat(audio):** Soniox STT + TTS provider (`sx`) — async speech-to-text (`stt-async-v5`, `stt-async-v4`) and real-time text-to-speech (`tts-rt-v1`) ([#9579](https://github.com/diegosouzapw/OmniRoute/pull/9579))
- Show cache-read and cache-write token counts in request log rows and details when providers
report them. (#9620)
@@ -148,6 +152,22 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
- **feat(admission) — direct pushes:** adaptive overload/pressure controls with shared admission wired across the LLM routes, plus mutation-test registration for the capability-filter suite
- **feat(agentrouter) — direct pushes:** support Claude and Codex protocols — infer the protocol from the client endpoint and honor the alternate protocol through the chat pipeline
- **feat(providers) — direct pushes:** ChatGPT Web session credential guide with a Cookie Editor fast-path (canonical chromewebstore install link) and web-session fast-path test coverage
- **feat(sse):** honor provider-rule lock scope for agentrouter (connection vs model) ([#10419](https://github.com/diegosouzapw/OmniRoute/pull/10419))
- **feat(ocr):** Vertex AI DeepSeek-OCR provider ([#10398](https://github.com/diegosouzapw/OmniRoute/pull/10398))
- **feat(providers):** derive imageToText from the OCR registry + chutes dots.ocr seed ([#10400](https://github.com/diegosouzapw/OmniRoute/pull/10400))
- **feat(ocr):** multi-provider /v1/ocr with transformation layer (Azure Document Intelligence) ([#10283](https://github.com/diegosouzapw/OmniRoute/pull/10283))
- **feat(providers):** declare imageToText serviceKind on major vision providers ([#10275](https://github.com/diegosouzapw/OmniRoute/pull/10275))
- **feat(bridge):** native-vision skip guard + configurable describe output cap ([#10289](https://github.com/diegosouzapw/OmniRoute/pull/10289))
- **feat(bridge):** normalize images to 2048px long edge before vision describe self-call ([#10287](https://github.com/diegosouzapw/OmniRoute/pull/10287))
- **feat(sse):** restate agentrouter quota 403/400 as retryable 429 with provider-scoped error rules ([#10335](https://github.com/diegosouzapw/OmniRoute/pull/10335))
- **feat(sse):** add i-have-adhd output style to compression catalog ([#10271](https://github.com/diegosouzapw/OmniRoute/pull/10271))
- **feat(codex):** add OAuth fingerprint convergence modes ([#10243](https://github.com/diegosouzapw/OmniRoute/pull/10243)) — thanks @xz-dev
- **feat(i18n):** complete Portuguese (PT-PT) translation ([#10250](https://github.com/diegosouzapw/OmniRoute/pull/10250)) — thanks @DarkEsteves
- **feat(providers):** publish Poolside's probed Laguna Preview catalog ([#10216](https://github.com/diegosouzapw/OmniRoute/pull/10216)) — thanks @pacocartones
- **feat(crof):** advertise reasoning effort tiers incl. max from live discovery and registry ([#10062](https://github.com/diegosouzapw/OmniRoute/pull/10062)) — thanks @excessivechaos
- **feat(open-sse):** expose provider-level circuit breaker thresholds via env vars (#10040) ([#10046](https://github.com/diegosouzapw/OmniRoute/pull/10046)) — thanks @tiangao88
- **feat(dashboard):** Kimi 15% first-top-up campaign — dedicated tracked link + discount-first banner copy ([#10240](https://github.com/diegosouzapw/OmniRoute/pull/10240))
- **feat(providers):** integrate audited free-tier gateways ([#9210](https://github.com/diegosouzapw/OmniRoute/pull/9210))
### 🐛 Bug Fixes
@@ -553,6 +573,63 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
- **fix(i18n) — direct pushes:** restore/unescape HTML entities in UI strings, translate capability-filter messages, complete web-session guide translations and Vietnamese parity
- **fix(providers) — direct pushes:** repair the DeepAI registry import + executor
- **fix(deps) — direct pushes:** CVE-driven bumps (nanoid, dompurify, mermaid, js-yaml + transitive deps for 26 Dependabot alerts) and retained isolated-build runtime dependencies in the pack
- **fix(ci):** pin Build (advisory) to a hosted runner with memory provisioning ([#10408](https://github.com/diegosouzapw/OmniRoute/pull/10408))
- **fix(providers):** refresh the translate-path golden for the bailian Token Plan endpoint ([#10410](https://github.com/diegosouzapw/OmniRoute/pull/10410))
- **fix(sse):** surface Qwen/Alibaba personal Token Plan quota in dashboard and preflight ([#10290](https://github.com/diegosouzapw/OmniRoute/pull/10290))
- **fix(deps):** pin next to an exact version so a fresh upstream release cannot break installs ([#10340](https://github.com/diegosouzapw/OmniRoute/pull/10340))
- **fix(types):** restore custom model output limit contract ([#10339](https://github.com/diegosouzapw/OmniRoute/pull/10339)) — thanks @backryun
- **fix(sse):** stop the executor-contract guard from hot-looping the router ([#10373](https://github.com/diegosouzapw/OmniRoute/pull/10373))
- **fix(types):** validate nonstreaming JSON contracts ([#10258](https://github.com/diegosouzapw/OmniRoute/pull/10258)) — thanks @backryun
- **fix(types):** narrow refresh token rotation inputs ([#10257](https://github.com/diegosouzapw/OmniRoute/pull/10257)) — thanks @backryun
- **fix(types):** normalize executor result contracts ([#10256](https://github.com/diegosouzapw/OmniRoute/pull/10256)) — thanks @backryun
- **fix(types):** align Responses stream options ([#10255](https://github.com/diegosouzapw/OmniRoute/pull/10255)) — thanks @backryun
- **fix(types):** narrow combo credential preflight ([#10254](https://github.com/diegosouzapw/OmniRoute/pull/10254)) — thanks @backryun
- **fix(compression):** cap countTextTokens at 50k chars and strip base64 data URIs ([#10118](https://github.com/diegosouzapw/OmniRoute/pull/10118)) — thanks @adevwithpurpose
- **fix(ci):** clear base-reds on release/v3.8.50 (round 4) ([#10260](https://github.com/diegosouzapw/OmniRoute/pull/10260))
- **fix(sse):** extract perplexity-web answers from workflow_block ([#10259](https://github.com/diegosouzapw/OmniRoute/pull/10259)) — thanks @jeyhunfaslanov
- **fix(mcp):** persist and re-attach Gemini thoughtSignature on the direct Claude<->Gemini path ([#9448](https://github.com/diegosouzapw/OmniRoute/pull/9448)) — thanks @Sam280903
- **fix(opencode-plugin):** respect log level for lifecycle output (#8982) ([#9316](https://github.com/diegosouzapw/OmniRoute/pull/9316)) — thanks @xiaoyaner0201
- **fix(providers):** raise default provider probe timeout from 5s to 8s ([#9283](https://github.com/diegosouzapw/OmniRoute/pull/9283)) — thanks @Sam280903
- **fix(opencode-plugin):** stop warning when an auto combo replaces its expected /v1/models twin (#8983) ([#9042](https://github.com/diegosouzapw/OmniRoute/pull/9042)) — thanks @xiaoyaner0201
- **fix(opencode):** force CLI User-Agent when CLI identity synthesis is enabled ([#10222](https://github.com/diegosouzapw/OmniRoute/pull/10222)) — thanks @adevwithpurpose
- **fix(deepseek-web):** classify business auth rejection as 401 ([#10218](https://github.com/diegosouzapw/OmniRoute/pull/10218)) — thanks @Zartharas
- **fix(combo):** make failoverBeforeRetry actually skip the same-model retry ([#10217](https://github.com/diegosouzapw/OmniRoute/pull/10217)) — thanks @hartmark
- **fix(responses):** preserve case-insensitive combo names before Codex rewrite ([#10177](https://github.com/diegosouzapw/OmniRoute/pull/10177)) — thanks @ddarkr
- **fix(discovery):** parse reasoning tiers nested under metadata.reasoning.supported_efforts ([#10138](https://github.com/diegosouzapw/OmniRoute/pull/10138)) — thanks @excessivechaos
- **fix(combo):** isolate session stickiness by combo ([#10137](https://github.com/diegosouzapw/OmniRoute/pull/10137)) — thanks @hydraxman
- **fix(combo):** default chaos SSE to comment-only for OpenAI-compatible clients ([#10128](https://github.com/diegosouzapw/OmniRoute/pull/10128)) — thanks @herjarsa
- **fix(kimi):** normalize MFJS tool schemas ([#10079](https://github.com/diegosouzapw/OmniRoute/pull/10079)) — thanks @xz-dev
- **fix(mcp):** move pack validation out of unit suite ([#10065](https://github.com/diegosouzapw/OmniRoute/pull/10065)) — thanks @yansigit
- **fix(zed-hosted):** send the provider wire values cloud.zed.dev accepts ([#10051](https://github.com/diegosouzapw/OmniRoute/pull/10051)) — thanks @ARC345
- **fix(ci):** repair and wire the two live-server E2E suites ([#10050](https://github.com/diegosouzapw/OmniRoute/pull/10050)) — thanks @ARC345
- **fix(reasoning):** preserve and replay assistant turns ([#10045](https://github.com/diegosouzapw/OmniRoute/pull/10045)) — thanks @jackjinke
- **fix(types):** tighten chatCore helper contracts ([#10175](https://github.com/diegosouzapw/OmniRoute/pull/10175)) — thanks @backryun
- **fix(cli):** read the full provider catalog instead of the 6-entry fallback ([#10097](https://github.com/diegosouzapw/OmniRoute/pull/10097)) — thanks @amartinawi
- **fix(cli):** stop swallowing non-2xx responses into benign-looking results ([#10092](https://github.com/diegosouzapw/OmniRoute/pull/10092)) — thanks @amartinawi
- **fix(cli):** openapi endpoints/paths/validate accept the served catalog shape ([#10091](https://github.com/diegosouzapw/OmniRoute/pull/10091)) — thanks @amartinawi
- **fix(cli):** doctor detects prebuilt better-sqlite3 binaries ([#10090](https://github.com/diegosouzapw/OmniRoute/pull/10090)) — thanks @amartinawi
- **fix(providers):** kilo-gateway authType should be optional, not apikey ([#10086](https://github.com/diegosouzapw/OmniRoute/pull/10086)) — thanks @TengSivtean
- **fix(cli):** strip inline comments when parsing .env values ([#10101](https://github.com/diegosouzapw/OmniRoute/pull/10101)) — thanks @amartinawi
- **fix(logging):** document CHAT_LOG_MAX_BODY_KB, capture messageCount for Responses API bodies ([#10038](https://github.com/diegosouzapw/OmniRoute/pull/10038)) — thanks @hartmark
- **fix(dashboard):** expose OpenAI Responses store toggle for non-Codex connections ([#10121](https://github.com/diegosouzapw/OmniRoute/pull/10121)) — thanks @hartmark
- **fix(combo):** clear LKGP pin when its target fails, not only set it on success ([#10034](https://github.com/diegosouzapw/OmniRoute/pull/10034)) — thanks @hartmark
- **fix(sse):** provider-response summary format bugs (dashboard Provider Response panel) ([#10037](https://github.com/diegosouzapw/OmniRoute/pull/10037)) — thanks @hartmark
- **fix(responses-api):** tool call after reasoning collided on the same output_index ([#10025](https://github.com/diegosouzapw/OmniRoute/pull/10025)) — thanks @hartmark
- **fix(responses-api):** explicit function-tool declaration must win over apply_patch-is-custom fallback ([#10041](https://github.com/diegosouzapw/OmniRoute/pull/10041)) — thanks @hartmark
- **fix(kimi):** recupera limite temporario sem bloquear conta ([#10058](https://github.com/diegosouzapw/OmniRoute/pull/10058)) — thanks @bortolidiego
- **fix(translator):** preserve Responses custom tools for OpenAI-compatible providers ([#10114](https://github.com/diegosouzapw/OmniRoute/pull/10114)) — thanks @mtb-ninja
- **fix(providers):** xai-oauth chat→responses body + missing breaker import (#10165) ([#10170](https://github.com/diegosouzapw/OmniRoute/pull/10170)) — thanks @nordz0r
- **fix(ollama-cloud):** map xhigh reasoning effort to max ([#10160](https://github.com/diegosouzapw/OmniRoute/pull/10160)) — thanks @Chewji9875
- **fix(translator):** strip Codex encrypted tool-schema key for Gemini/Antigravity ([#10053](https://github.com/diegosouzapw/OmniRoute/pull/10053)) — thanks @XDayonline
- **fix(combo):** preserve OpenCode Free oc/ prefix for connections ([#10180](https://github.com/diegosouzapw/OmniRoute/pull/10180)) — thanks @AStupidBear
- **fix(sse):** apply free-tier filter to auto/best-free on chat path ([#10199](https://github.com/diegosouzapw/OmniRoute/pull/10199)) — thanks @ggdayup
- **fix(providers):** default missing cache_control.ttl to 1h on the native Claude OAuth path ([#10221](https://github.com/diegosouzapw/OmniRoute/pull/10221)) — thanks @jeff-alves
- **fix(ci):** clear base-reds on release/v3.8.50 (round 3) ([#10213](https://github.com/diegosouzapw/OmniRoute/pull/10213))
- **fix(security):** correct XML double-unescape and non-CSPRNG nonce from CodeQL sweep ([#10154](https://github.com/diegosouzapw/OmniRoute/pull/10154))
- **fix(docker):** eliminate npm-bundled CVEs from the published image ([#10182](https://github.com/diegosouzapw/OmniRoute/pull/10182))
- **fix(security):** resolve open CodeQL alerts ([#10188](https://github.com/diegosouzapw/OmniRoute/pull/10188))
- **fix(dashboard):** retarget Kimi promo CTA to the API platform aff link ([#10200](https://github.com/diegosouzapw/OmniRoute/pull/10200))
- **fix(build):** repair broken production build, red lint gate and SWR crash ([#10198](https://github.com/diegosouzapw/OmniRoute/pull/10198))
### 📝 Maintenance
@@ -698,160 +775,178 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
- **deps (rollup):** dependency bumps and lockfile maintenance across the cycle — Dependabot groups and manual CVE-driven bumps ([#9081](https://github.com/diegosouzapw/OmniRoute/pull/9081), [#9082](https://github.com/diegosouzapw/OmniRoute/pull/9082), [#9427](https://github.com/diegosouzapw/OmniRoute/pull/9427), [#9458](https://github.com/diegosouzapw/OmniRoute/pull/9458), [#9459](https://github.com/diegosouzapw/OmniRoute/pull/9459), [#9461](https://github.com/diegosouzapw/OmniRoute/pull/9461), [#9462](https://github.com/diegosouzapw/OmniRoute/pull/9462), [#9472](https://github.com/diegosouzapw/OmniRoute/pull/9472))
- **docs/chore (rollup):** documentation, refactoring and repository-hygiene upkeep across the cycle ([#8954](https://github.com/diegosouzapw/OmniRoute/pull/8954), [#8991](https://github.com/diegosouzapw/OmniRoute/pull/8991), [#9059](https://github.com/diegosouzapw/OmniRoute/pull/9059), [#9194](https://github.com/diegosouzapw/OmniRoute/pull/9194), [#9258](https://github.com/diegosouzapw/OmniRoute/pull/9258), [#9508](https://github.com/diegosouzapw/OmniRoute/pull/9508))
- **main-branch plumbing (rollup):** work that landed on `main` between cycles and was carried into this one — the Mergify merge-queue migration and tuning (#7168, #7179, #7216, #7220, #7225), npm-publish unblock via dynamic runner + CI build reuse (#8941), CodeQL-driven e2e mock hardening (#7559), hermetic self-ref guard (#6634, #7341), coverage-baseline tightening (#7347), Dependabot alert resolutions via npm overrides (#8067, #8070), README flag/doc-link polish (#8317), and the v3.8.49 release plumbing itself (#7076)
- **deps:** bump the development group across 1 directory with 22 updates ([#10043](https://github.com/diegosouzapw/OmniRoute/pull/10043)) — thanks @app/dependabot
- **deps:** bump electron from 43.2.0 to 43.3.0 in /electron ([#10042](https://github.com/diegosouzapw/OmniRoute/pull/10042)) — thanks @app/dependabot
- **maint(release):** 45 direct pushes to the release branch with no PR ref — base-red and quality-gate repairs, i18n string completion and stream/type fixes (quality ×6, i18n ×5, deps ×3, agentrouter ×3, providers ×2, release ×2, security ×2, logging ×2)
- **maint(repo):** 29 chore/ci/test/docs commits rolled up — quality baselines, mutation registration, CI re-triggers, doc restructure and repo hygiene (#10187, #10189, #10190, #10193, #10196, #10203, #10204, #10205, #10207, #10210, #10236, #10318)
### 🙌 Contributors
Thanks to everyone whose work landed in v3.8.50:
| Contributor | PRs / Issues |
| --- | --- |
| [@AbdullahFageeh](https://github.com/AbdullahFageeh) | #9087 |
| [@adevwithpurpose](https://github.com/adevwithpurpose) | #9790 |
| [@adrianojiu](https://github.com/adrianojiu) | #8438 |
| [@agisota](https://github.com/agisota) | #9837 |
| [@AgnesRiber](https://github.com/AgnesRiber) | #9718, #9976 |
| [@ahmet-cetinkaya](https://github.com/ahmet-cetinkaya) | #8878 |
| [@AIB1TAL0S](https://github.com/AIB1TAL0S) | #9284 |
| [@AlanSyue](https://github.com/AlanSyue) | direct commit / report |
| [@alex-jordan547](https://github.com/alex-jordan547) | #9235, #9245, #9813 |
| [@AndrianBalanescu](https://github.com/AndrianBalanescu) | #8888, #8889, #8890, #8891, #8892, #8893, #8894, #8895 |
| [@AnhLead](https://github.com/AnhLead) | #9722 |
| [@aniketshukla1](https://github.com/aniketshukla1) | #9148 |
| [@Anjielon](https://github.com/Anjielon) | #8776 |
| [@apoapostolov](https://github.com/apoapostolov) | #8916 |
| [@ARC345](https://github.com/ARC345) | #9628 |
| [@artickc](https://github.com/artickc) | #8571, #8578, #8791, #8843, #8870, #8927, #8974, #9097, #9549 |
| [@Arul-](https://github.com/Arul-) | #9761 |
| [@b1nhm1nh](https://github.com/b1nhm1nh) | direct commit / report |
| [@backryun](https://github.com/backryun) | #8228, #8451, #8627, #8809, #8818, #9084, #9086, #9090, #9091, #9092, #9093, #9114, #9119, #9120, #9122, #9135, #9136, #9137, #9138, #9139, #9141, #9561, #9562, #9563, #9564, #9565, #9566, #9742, #9747, #9748, #9751, #9753, #9755, #9791, #9792, #9793, #9795, #9796, #9797, #9798, #9920, #9972, #9973, #9974, #9975, #9977, #9978, #9979, #9984, #9986, #9987, #9988, #9989, #9990, #9998, #10087, #10088, #10134, #10178 |
| [@Benson-mk](https://github.com/Benson-mk) | #8369 |
| [@benzntech](https://github.com/benzntech) | #9810, #9812, #9939 |
| [@Bl0ck154](https://github.com/Bl0ck154) | #9231 |
| [@branben](https://github.com/branben) | #9940 |
| [@Chewji9875](https://github.com/Chewji9875) | #9257, #9420, #9821, #9994 |
| [@chirag127](https://github.com/chirag127) | #6674 |
| [@chloeassistant](https://github.com/chloeassistant) | #9675, #9746 |
| [@configurowebmax](https://github.com/configurowebmax) | #8877 |
| [@corefusiion](https://github.com/corefusiion) | #8285 |
| [@costaeder](https://github.com/costaeder) | #8626, #8629, #8630 |
| [@csoftware-arigpt](https://github.com/csoftware-arigpt) | #3440 |
| [@DaDecky](https://github.com/DaDecky) | direct commit / report |
| [@ddarkr](https://github.com/ddarkr) | #9035, #9036 |
| [@Dingding-leo](https://github.com/Dingding-leo) | #7987, #8640, #8678, #8704, #8774, #8790, #8808, #8817 |
| [@DinonowDev](https://github.com/DinonowDev) | #8804 |
| [@Dragost](https://github.com/Dragost) | #8339 |
| [@dsitmilis](https://github.com/dsitmilis) | direct commit / report |
| [@Egorich-print](https://github.com/Egorich-print) | #9001, #9020, #9058 |
| [@engmarcosjr](https://github.com/engmarcosjr) | #9993 |
| [@epsilonode](https://github.com/epsilonode) | #8871 |
| [@ervareza](https://github.com/ervareza) | direct commit / report |
| [@fajarhide](https://github.com/fajarhide) | #9191, #9198 |
| [@fenix007](https://github.com/fenix007) | #9618 |
| [@Gecky2102](https://github.com/Gecky2102) | #9280 |
| [@Gioxaa](https://github.com/Gioxaa) | #9162, #9171 |
| [@HaoNgo232](https://github.com/HaoNgo232) | direct commit / report |
| [@Hariprajwal](https://github.com/Hariprajwal) | #9922 |
| [@hartmark](https://github.com/hartmark) | #9635, #9704, #9711, #9712, #9727, #9734, #9735, #9738, #9741, #9744, #9745, #9822 |
| [@Hdiaktoros](https://github.com/Hdiaktoros) | #8930 |
| [@HectorBernstorff](https://github.com/HectorBernstorff) | direct commit / report |
| [@HellFiveOsborn](https://github.com/HellFiveOsborn) | #9248 |
| [@herjarsa](https://github.com/herjarsa) | #9714, #9816, #9937, #9946 |
| [@horacecar](https://github.com/horacecar) | #7679 |
| [@HouMinXi](https://github.com/HouMinXi) | #8886, #8904, #8905, #8976, #8984, #9079, #9106, #9207, #9242, #9328, #9340, #9342, #9351, #9365, #9380, #9381, #9392, #9449, #9482, #9483, #9509, #9510, #9572, #9631, #9634, #9695, #9929 |
| [@hppsc1215](https://github.com/hppsc1215) | #8970 |
| [@Iammilansoni](https://github.com/Iammilansoni) | #9353, #9397 |
| [@ikelvingo](https://github.com/ikelvingo) | #8591, #8872, #9053 |
| [@infinit-X](https://github.com/infinit-X) | #9095 |
| [@isaaclb98](https://github.com/isaaclb98) | #9730 |
| [@jackjinke](https://github.com/jackjinke) | #9556, #9601, #10005 |
| [@jax-novita](https://github.com/jax-novita) | #8913 |
| [@jhordanjw123](https://github.com/jhordanjw123) | #8736 |
| [@jktan0504](https://github.com/jktan0504) | #9025 |
| [@joachimBrindeau](https://github.com/joachimBrindeau) | #9200 |
| [@JoshimOfficial](https://github.com/JoshimOfficial) | #9011 |
| [@jowimila](https://github.com/jowimila) | #9325 |
| [@JxnLexn](https://github.com/JxnLexn) | #8933, #8940, #8944, #8949 |
| [@Kaedo17](https://github.com/Kaedo17) | #8922 |
| [@khoazero123](https://github.com/khoazero123) | #9272 |
| [@KittisakT](https://github.com/KittisakT) | #9423 |
| [@KooshaPari](https://github.com/KooshaPari) | #7329 |
| [@larin-vas](https://github.com/larin-vas) | #9828 |
| [@lazysaltyfish](https://github.com/lazysaltyfish) | direct commit / report |
| [@LeonG606](https://github.com/LeonG606) | #9457 |
| [@Llliao1113](https://github.com/Llliao1113) | #8921 |
| [@lucasalx](https://github.com/lucasalx) | #9919 |
| [@lucasmellos](https://github.com/lucasmellos) | #8925 |
| [@lukiod](https://github.com/lukiod) | #8828 |
| [@luoyide](https://github.com/luoyide) | direct commit / report |
| [@mad-gooze](https://github.com/mad-gooze) | #9052 |
| [@maisdesign](https://github.com/maisdesign) | #8858 |
| [@marchlhw](https://github.com/marchlhw) | #9050 |
| [@matiasbaglieri](https://github.com/matiasbaglieri) | #9707 |
| [@maxmad64bis](https://github.com/maxmad64bis) | #9150, #9246, #9291, #9414 |
| [@McLuck](https://github.com/McLuck) | #8914 |
| [@Michael-Rocco-Goldmann](https://github.com/Michael-Rocco-Goldmann) | #9770, #9773, #9777, #9787 |
| [@MichaelYcJo](https://github.com/MichaelYcJo) | #8244 |
| [@minhnhat166](https://github.com/minhnhat166) | direct commit / report |
| [@MohitRawat017](https://github.com/MohitRawat017) | #8718, #8772, #9605 |
| [@Momen4444](https://github.com/Momen4444) | #9612 |
| [@MrShitFox](https://github.com/MrShitFox) | #9826 |
| [@MumuTW](https://github.com/MumuTW) | #8839 |
| [@mvanhorn](https://github.com/mvanhorn) | #9542 |
| [@Mynacol](https://github.com/Mynacol) | #9733 |
| [@nguyenha935](https://github.com/nguyenha935) | #9044, #9215 |
| [@nosolosoft](https://github.com/nosolosoft) | #8900 |
| [@oyi77](https://github.com/oyi77) | #8299, #8752, #9158, #9818 |
| [@PixmaNts](https://github.com/PixmaNts) | #9432 |
| [@pizzav-xyz](https://github.com/pizzav-xyz) | #9077 |
| [@Poid-ZA](https://github.com/Poid-ZA) | #9467 |
| [@Prudhvivuda](https://github.com/Prudhvivuda) | #8807, #9014, #9015, #9016 |
| [@qianze0628](https://github.com/qianze0628) | #9038 |
| [@raflyazf](https://github.com/raflyazf) | direct commit / report |
| [@Rahulsharma0810](https://github.com/Rahulsharma0810) | #8961 |
| [@rinseaid](https://github.com/rinseaid) | #8945, #9037, #9932, #9933, #9969, #9982 |
| [@rixzkiye](https://github.com/rixzkiye) | direct commit / report |
| [@RobertsXML](https://github.com/RobertsXML) | direct commit / report |
| [@royanrosyad85](https://github.com/royanrosyad85) | direct commit / report |
| [@rushsinging](https://github.com/rushsinging) | #8947 |
| [@ryan-brosas](https://github.com/ryan-brosas) | #9693 |
| [@ryanngit](https://github.com/ryanngit) | direct commit / report |
| [@sadSanta-07](https://github.com/sadSanta-07) | #9938 |
| [@SalyyS1](https://github.com/SalyyS1) | direct commit / report |
| [@Sam280903](https://github.com/Sam280903) | #9274, #9278, #9281 |
| [@seakleangnhak](https://github.com/seakleangnhak) | direct commit / report |
| [@seanford](https://github.com/seanford) | #8523 |
| [@SemonCat](https://github.com/SemonCat) | direct commit / report |
| [@shixi-li](https://github.com/shixi-li) | #9022, #9513, #10001 |
| [@soulhakr](https://github.com/soulhakr) | #8799 |
| [@stanleytejakusuma](https://github.com/stanleytejakusuma) | #9610 |
| [@Stazyu](https://github.com/Stazyu) | #9007, #9226, #9438 |
| [@SupremeNexas](https://github.com/SupremeNexas) | #9913 |
| [@swingtempo](https://github.com/swingtempo) | #9307 |
| [@szzhoujiarui](https://github.com/szzhoujiarui) | #9218 |
| [@tald26](https://github.com/tald26) | #9959 |
| [@taltas](https://github.com/taltas) | direct commit / report |
| [@TechNickAI](https://github.com/TechNickAI) | #9251 |
| [@TengSivtean](https://github.com/TengSivtean) | #10000, #10002 |
| [@TheFrenchGhosty](https://github.com/TheFrenchGhosty) | #9326 |
| [@tuxmonteiro](https://github.com/tuxmonteiro) | #9065 |
| [@vinogradovnet](https://github.com/vinogradovnet) | #9581 |
| [@VXNCXNX](https://github.com/VXNCXNX) | #9111, #9783 |
| [@wgordon17](https://github.com/wgordon17) | #8909, #9233, #9441, #9619 |
| [@Witroch4](https://github.com/Witroch4) | #8713 |
| [@witt3rd](https://github.com/witt3rd) | #9962, #9963 |
| [@xiaoyaner0201](https://github.com/xiaoyaner0201) | #8757, #8869, #8876, #8883, #8906, #8931, #9021, #9027, #9452 |
| [@xz-dev](https://github.com/xz-dev) | #8908, #9199, #9205, #9262, #9290, #9313, #9555, #9569, #9629, #9788, #9983 |
| [@yansigit](https://github.com/yansigit) | #9834, #9911, #9917, #9921 |
| [@yidecode](https://github.com/yidecode) | direct commit / report |
| [@yulinlina](https://github.com/yulinlina) | #10013 |
| [@yutuknown](https://github.com/yutuknown) | #8999 |
| [@zabrodschiipavel-sketch](https://github.com/zabrodschiipavel-sketch) | #9312 |
| [@Zartharas](https://github.com/Zartharas) | #9161, #9164, #9181, #9182, #9184, #9185, #9186, #9189, #9294, #9825, #9833, #9936, #9965, #9992 |
| [@Zenlyte](https://github.com/Zenlyte) | #9005 |
| [@zhiru](https://github.com/zhiru) | #9099, #9101 |
| [@ziuus](https://github.com/ziuus) | #8912 |
| [@zuckdorsey](https://github.com/zuckdorsey) | #9723 |
| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer |
| Contributor | PRs / Issues |
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [@AbdullahFageeh](https://github.com/AbdullahFageeh) | #9087 |
| [@adevwithpurpose](https://github.com/adevwithpurpose) | #9790, #10118, #10222 |
| [@adrianojiu](https://github.com/adrianojiu) | #8438 |
| [@agisota](https://github.com/agisota) | #9837 |
| [@AgnesRiber](https://github.com/AgnesRiber) | #9718, #9976 |
| [@ahmet-cetinkaya](https://github.com/ahmet-cetinkaya) | #8878 |
| [@AIB1TAL0S](https://github.com/AIB1TAL0S) | #9284 |
| [@AlanSyue](https://github.com/AlanSyue) | direct commit / report |
| [@alex-jordan547](https://github.com/alex-jordan547) | #9235, #9245, #9813 |
| [@amartinawi](https://github.com/amartinawi) | #10090, #10091, #10092, #10097, #10101 |
| [@AndrianBalanescu](https://github.com/AndrianBalanescu) | #8888, #8889, #8890, #8891, #8892, #8893, #8894, #8895 |
| [@AnhLead](https://github.com/AnhLead) | #9722 |
| [@aniketshukla1](https://github.com/aniketshukla1) | #9148 |
| [@Anjielon](https://github.com/Anjielon) | #8776 |
| [@apoapostolov](https://github.com/apoapostolov) | #8916 |
| [@ARC345](https://github.com/ARC345) | #9628, #10050, #10051 |
| [@artickc](https://github.com/artickc) | #8571, #8578, #8791, #8843, #8870, #8927, #8974, #9097, #9549 |
| [@Arul-](https://github.com/Arul-) | #9761 |
| [@AStupidBear](https://github.com/AStupidBear) | #10180 |
| [@b1nhm1nh](https://github.com/b1nhm1nh) | direct commit / report |
| [@backryun](https://github.com/backryun) | #8228, #8451, #8627, #8809, #8818, #9084, #9086, #9090, #9091, #9092, #9093, #9114, #9119, #9120, #9122, #9135, #9136, #9137, #9138, #9139, #9141, #9561, #9562, #9563, #9564, #9565, #9566, #9742, #9747, #9748, #9751, #9753, #9755, #9791, #9792, #9793, #9795, #9796, #9797, #9798, #9920, #9972, #9973, #9974, #9975, #9977, #9978, #9979, #9984, #9986, #9987, #9988, #9989, #9990, #9998, #10087, #10088, #10134, #10178, #10175, #10254, #10255, #10256, #10257, #10258, #10339 |
| [@Benson-mk](https://github.com/Benson-mk) | #8369 |
| [@benzntech](https://github.com/benzntech) | #9810, #9812, #9939 |
| [@Bl0ck154](https://github.com/Bl0ck154) | #9231 |
| [@bortolidiego](https://github.com/bortolidiego) | #10058 |
| [@branben](https://github.com/branben) | #9940 |
| [@Chewji9875](https://github.com/Chewji9875) | #9257, #9420, #9821, #9994, #10160 |
| [@chirag127](https://github.com/chirag127) | #6674 |
| [@chloeassistant](https://github.com/chloeassistant) | #9675, #9746 |
| [@configurowebmax](https://github.com/configurowebmax) | #8877 |
| [@corefusiion](https://github.com/corefusiion) | #8285 |
| [@costaeder](https://github.com/costaeder) | #8626, #8629, #8630 |
| [@csoftware-arigpt](https://github.com/csoftware-arigpt) | #3440 |
| [@DaDecky](https://github.com/DaDecky) | direct commit / report |
| [@DarkEsteves](https://github.com/DarkEsteves) | #10250 |
| [@ddarkr](https://github.com/ddarkr) | #9035, #9036, #10177 |
| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer |
| [@Dingding-leo](https://github.com/Dingding-leo) | #7987, #8640, #8678, #8704, #8774, #8790, #8808, #8817 |
| [@DinonowDev](https://github.com/DinonowDev) | #8804 |
| [@Dragost](https://github.com/Dragost) | #8339 |
| [@dsitmilis](https://github.com/dsitmilis) | direct commit / report |
| [@Egorich-print](https://github.com/Egorich-print) | #9001, #9020, #9058 |
| [@engmarcosjr](https://github.com/engmarcosjr) | #9993 |
| [@epsilonode](https://github.com/epsilonode) | #8871 |
| [@ervareza](https://github.com/ervareza) | direct commit / report |
| [@excessivechaos](https://github.com/excessivechaos) | #10062, #10138 |
| [@fajarhide](https://github.com/fajarhide) | #9191, #9198 |
| [@fenix007](https://github.com/fenix007) | #9618 |
| [@Gecky2102](https://github.com/Gecky2102) | #9280 |
| [@ggdayup](https://github.com/ggdayup) | #10199 |
| [@Gioxaa](https://github.com/Gioxaa) | #9162, #9171 |
| [@HaoNgo232](https://github.com/HaoNgo232) | direct commit / report |
| [@Hariprajwal](https://github.com/Hariprajwal) | #9922 |
| [@hartmark](https://github.com/hartmark) | #9635, #9704, #9711, #9712, #9727, #9734, #9735, #9738, #9741, #9744, #9745, #9822, #10025, #10034, #10037, #10038, #10041, #10121, #10217 |
| [@Hdiaktoros](https://github.com/Hdiaktoros) | #8930 |
| [@HectorBernstorff](https://github.com/HectorBernstorff) | direct commit / report |
| [@HellFiveOsborn](https://github.com/HellFiveOsborn) | #9248 |
| [@herjarsa](https://github.com/herjarsa) | #9714, #9816, #9937, #9946, #10128 |
| [@horacecar](https://github.com/horacecar) | #7679 |
| [@HouMinXi](https://github.com/HouMinXi) | #8886, #8904, #8905, #8976, #8984, #9079, #9106, #9207, #9242, #9328, #9340, #9342, #9351, #9365, #9380, #9381, #9392, #9449, #9482, #9483, #9509, #9510, #9572, #9631, #9634, #9695, #9929 |
| [@hppsc1215](https://github.com/hppsc1215) | #8970 |
| [@hydraxman](https://github.com/hydraxman) | #10137 |
| [@Iammilansoni](https://github.com/Iammilansoni) | #9353, #9397 |
| [@ikelvingo](https://github.com/ikelvingo) | #8591, #8872, #9053 |
| [@infinit-X](https://github.com/infinit-X) | #9095 |
| [@isaaclb98](https://github.com/isaaclb98) | #9730 |
| [@jackjinke](https://github.com/jackjinke) | #9556, #9601, #10005, #10045 |
| [@jax-novita](https://github.com/jax-novita) | #8913 |
| [@jeff-alves](https://github.com/jeff-alves) | #10221 |
| [@jeyhunfaslanov](https://github.com/jeyhunfaslanov) | #10259 |
| [@jhordanjw123](https://github.com/jhordanjw123) | #8736 |
| [@jktan0504](https://github.com/jktan0504) | #9025 |
| [@joachimBrindeau](https://github.com/joachimBrindeau) | #9200 |
| [@JoshimOfficial](https://github.com/JoshimOfficial) | #9011 |
| [@jowimila](https://github.com/jowimila) | #9325 |
| [@JxnLexn](https://github.com/JxnLexn) | #8933, #8940, #8944, #8949 |
| [@Kaedo17](https://github.com/Kaedo17) | #8922 |
| [@khoazero123](https://github.com/khoazero123) | #9272 |
| [@KittisakT](https://github.com/KittisakT) | #9423 |
| [@KooshaPari](https://github.com/KooshaPari) | #7329 |
| [@larin-vas](https://github.com/larin-vas) | #9828 |
| [@lazysaltyfish](https://github.com/lazysaltyfish) | direct commit / report |
| [@LeonG606](https://github.com/LeonG606) | #9457 |
| [@Llliao1113](https://github.com/Llliao1113) | #8921 |
| [@lucasalx](https://github.com/lucasalx) | #9919 |
| [@lucasmellos](https://github.com/lucasmellos) | #8925 |
| [@lukiod](https://github.com/lukiod) | #8828 |
| [@luoyide](https://github.com/luoyide) | direct commit / report |
| [@mad-gooze](https://github.com/mad-gooze) | #9052 |
| [@maisdesign](https://github.com/maisdesign) | #8858 |
| [@marchlhw](https://github.com/marchlhw) | #9050 |
| [@matiasbaglieri](https://github.com/matiasbaglieri) | #9707 |
| [@maxmad64bis](https://github.com/maxmad64bis) | #9150, #9246, #9291, #9414 |
| [@McLuck](https://github.com/McLuck) | #8914 |
| [@Michael-Rocco-Goldmann](https://github.com/Michael-Rocco-Goldmann) | #9770, #9773, #9777, #9787 |
| [@MichaelYcJo](https://github.com/MichaelYcJo) | #8244 |
| [@minhnhat166](https://github.com/minhnhat166) | direct commit / report |
| [@MohitRawat017](https://github.com/MohitRawat017) | #8718, #8772, #9605 |
| [@Momen4444](https://github.com/Momen4444) | #9612 |
| [@MrShitFox](https://github.com/MrShitFox) | #9826 |
| [@mtb-ninja](https://github.com/mtb-ninja) | #10114 |
| [@MumuTW](https://github.com/MumuTW) | #8839 |
| [@mvanhorn](https://github.com/mvanhorn) | #9542 |
| [@Mynacol](https://github.com/Mynacol) | #9733 |
| [@nguyenha935](https://github.com/nguyenha935) | #9044, #9215 |
| [@nordz0r](https://github.com/nordz0r) | #10170 |
| [@nosolosoft](https://github.com/nosolosoft) | #8900 |
| [@oyi77](https://github.com/oyi77) | #8299, #8752, #9158, #9818 |
| [@pacocartones](https://github.com/pacocartones) | #10216 |
| [@PixmaNts](https://github.com/PixmaNts) | #9432 |
| [@pizzav-xyz](https://github.com/pizzav-xyz) | #9077 |
| [@Poid-ZA](https://github.com/Poid-ZA) | #9467 |
| [@Prudhvivuda](https://github.com/Prudhvivuda) | #8807, #9014, #9015, #9016 |
| [@qianze0628](https://github.com/qianze0628) | #9038 |
| [@raflyazf](https://github.com/raflyazf) | direct commit / report |
| [@Rahulsharma0810](https://github.com/Rahulsharma0810) | #8961 |
| [@rinseaid](https://github.com/rinseaid) | #8945, #9037, #9932, #9933, #9969, #9982 |
| [@rixzkiye](https://github.com/rixzkiye) | direct commit / report |
| [@RobertsXML](https://github.com/RobertsXML) | direct commit / report |
| [@royanrosyad85](https://github.com/royanrosyad85) | direct commit / report |
| [@rushsinging](https://github.com/rushsinging) | #8947 |
| [@ryan-brosas](https://github.com/ryan-brosas) | #9693 |
| [@ryanngit](https://github.com/ryanngit) | direct commit / report |
| [@sadSanta-07](https://github.com/sadSanta-07) | #9938 |
| [@SalyyS1](https://github.com/SalyyS1) | direct commit / report |
| [@Sam280903](https://github.com/Sam280903) | #9274, #9278, #9281, #9283, #9448 |
| [@seakleangnhak](https://github.com/seakleangnhak) | direct commit / report |
| [@seanford](https://github.com/seanford) | #8523 |
| [@SemonCat](https://github.com/SemonCat) | direct commit / report |
| [@shixi-li](https://github.com/shixi-li) | #9022, #9513, #10001 |
| [@soulhakr](https://github.com/soulhakr) | #8799 |
| [@stanleytejakusuma](https://github.com/stanleytejakusuma) | #9610 |
| [@Stazyu](https://github.com/Stazyu) | #9007, #9226, #9438 |
| [@SupremeNexas](https://github.com/SupremeNexas) | #9913 |
| [@swingtempo](https://github.com/swingtempo) | #9307 |
| [@szzhoujiarui](https://github.com/szzhoujiarui) | #9218 |
| [@tald26](https://github.com/tald26) | #9959 |
| [@taltas](https://github.com/taltas) | direct commit / report |
| [@TechNickAI](https://github.com/TechNickAI) | #9251 |
| [@TengSivtean](https://github.com/TengSivtean) | #10000, #10002, #10086 |
| [@TheFrenchGhosty](https://github.com/TheFrenchGhosty) | #9326 |
| [@tiangao88](https://github.com/tiangao88) | #10046 |
| [@tuxmonteiro](https://github.com/tuxmonteiro) | #9065 |
| [@vinogradovnet](https://github.com/vinogradovnet) | #9581 |
| [@VXNCXNX](https://github.com/VXNCXNX) | #9111, #9783 |
| [@wgordon17](https://github.com/wgordon17) | #8909, #9233, #9441, #9619 |
| [@Witroch4](https://github.com/Witroch4) | #8713 |
| [@witt3rd](https://github.com/witt3rd) | #9962, #9963 |
| [@XDayonline](https://github.com/XDayonline) | #10053 |
| [@xiaoyaner0201](https://github.com/xiaoyaner0201) | #8757, #8869, #8876, #8883, #8906, #8931, #9021, #9027, #9452, #9042, #9316 |
| [@xz-dev](https://github.com/xz-dev) | #8908, #9199, #9205, #9262, #9290, #9313, #9555, #9569, #9629, #9788, #9983, #10079, #10243 |
| [@yansigit](https://github.com/yansigit) | #9834, #9911, #9917, #9921, #10065 |
| [@yidecode](https://github.com/yidecode) | direct commit / report |
| [@yulinlina](https://github.com/yulinlina) | #10013 |
| [@yutuknown](https://github.com/yutuknown) | #8999 |
| [@zabrodschiipavel-sketch](https://github.com/zabrodschiipavel-sketch) | #9312 |
| [@Zartharas](https://github.com/Zartharas) | #9161, #9164, #9181, #9182, #9184, #9185, #9186, #9189, #9294, #9825, #9833, #9936, #9965, #9992, #10218 |
| [@Zenlyte](https://github.com/Zenlyte) | #9005 |
| [@zhiru](https://github.com/zhiru) | #9099, #9101 |
| [@ziuus](https://github.com/ziuus) | #8912 |
| [@zuckdorsey](https://github.com/zuckdorsey) | #9723 |
---
@@ -1148,6 +1243,7 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62
- **feat(quality):** temporary relax of complexity/file-size ratchets for v3.8.50-3.8.54 PREPARE phase ([#8767](https://github.com/diegosouzapw/OmniRoute/pull/8767))
- **feat(db):** let the migration runner scan extra namespaced directories ([#8770](https://github.com/diegosouzapw/OmniRoute/pull/8770))
- **feat(api):** prompt-cache health summary endpoint and analytics tab ([#8827](https://github.com/diegosouzapw/OmniRoute/pull/8827))
### ⚡ Performance
- **perf(db):** project columns + composite index in getProviderConnections ([#6918](https://github.com/diegosouzapw/OmniRoute/pull/6918)) — thanks @oyi77
@@ -1163,6 +1259,7 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62
- **perf:** lazy provider init, P2C quota cache, structuredClone elimination, getSettings→getCachedSettings (batch 2) ([#7893](https://github.com/diegosouzapw/OmniRoute/pull/7893)) — thanks @oyi77
- **perf(api):** singleflight version lookups ([#8301](https://github.com/diegosouzapw/OmniRoute/pull/8301)) — thanks @RaviTharuma
- **perf(api):** skip the full catalog build for quota-exclusive keys ([#8771](https://github.com/diegosouzapw/OmniRoute/pull/8771))
### 🐛 Bug Fixes
- **fix:** add re-entrancy guard to token health check sweep ([#6917](https://github.com/diegosouzapw/OmniRoute/pull/6917)) — thanks @oyi77
@@ -1562,7 +1659,7 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62
- **fix(resilience):** a combo step "pinned" to one fingerprint account (mimocode/mcode/opencode multi-account providers) never actually resolved to that account, so it couldn't fail over when the pinned account was depleted ([#6696](https://github.com/diegosouzapw/OmniRoute/issues/6696), relates #6612) — the combo builder UI encodes an account pin as a composite connectionId (`${rowId}|fp|${fingerprint}`, `src/lib/combos/builderOptions.ts`), but `expandTargetsByFingerprints()` (`open-sse/services/combo/fingerprintExpansion.ts`) looked that composite string up directly in `connectionById` (keyed by real DB row ids), got `undefined`, and passed the target through unchanged, still carrying the bogus composite id — so downstream credential resolution could never match it either. `expandTargetsByFingerprints()` now splits the `|fp|` composite id back into the real connection row id + the pinned fingerprint (new `splitFingerprintPin()` helper) before any lookup, resolving the target to the real connectionId (with the pinned fingerprint carried on the new `pinnedFingerprint` field) instead of the inert composite string. Regression guard: `tests/unit/combo-fingerprint-pin-6696.test.ts`. ([#6732](https://github.com/diegosouzapw/OmniRoute/pull/6732))
- **fix(api):** Responses passthrough emitted event-only SSE frames (no `data:` line) for every dropped commentary event, breaking the OpenAI Python SDK's `sse.json()` parser ([#6561](https://github.com/diegosouzapw/OmniRoute/issues/6561)), follow-up to #6199/#6232 — the commentary-drop `continue;` branches in `open-sse/utils/stream.ts` skipped the `data:` line for a dropped commentary event but never cleared the already-buffered `event:` line for that same frame, so the next blank line flushed the stale `event:` line alone. Both drop sites now call `clearPendingPassthroughEvent()` before `continue`, discarding the buffered prefix along with the dropped payload; the commentary-drop decision itself was extracted into a new `open-sse/utils/responsesCommentaryDrop.ts` so the fix does not grow the frozen `stream.ts`. Regression guard: `tests/unit/responses-commentary-event-frame-6561.test.ts` (realistic `event:\ndata:\n\n` frames — the existing #6199 test only used bare `data:` lines and never exercised this path). ([#6735](https://github.com/diegosouzapw/OmniRoute/pull/6735))
- **fix(compression):** `/api/compression/preview`'s top-level `originalTokens`/`compressedTokens` diverged from `engineBreakdown[0]`'s counts for the same single-engine run (tiktoken outer counts vs the `JSON.stringify(...).length/4` estimate per engine), worst on small inputs. A new `reconcileSingleEngineTokens()` overwrites the single-engine breakdown entry with the outer, more accurate figures; multi-step pipeline breakdowns are left untouched ([#6488](https://github.com/diegosouzapw/OmniRoute/issues/6488)). Regression guard: `tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts`. ([#6741](https://github.com/diegosouzapw/OmniRoute/pull/6741))
- **fix(resilience):** account selection could pick an account already out of quota upstream on every credentialed route except `chat`/`codex` ([#6686](https://github.com/diegosouzapw/OmniRoute/issues/6686)) — `getProviderCredentials()` (`src/sse/services/auth.ts`) only skips a connection when a *local cache* already flags it exhausted (`isQuotaExhaustedForRequest`/`src/domain/quotaCache.ts`); it never itself calls the registered upstream `QuotaFetcher`. Only `getProviderCredentialsWithQuotaPreflight()` performs that live upstream check, and it was wired into exactly 2 call sites (`src/sse/handlers/chat.ts`, `src/app/api/internal/codex-responses-ws/route.ts`) — every other credentialed route (`rerank`, `images/generations`, `images/edits`, `audio/transcriptions|speech|translations`, `videos/generations`, `music/generations`, `ocr`, `providers/[provider]/embeddings`, `providers/[provider]/images/generations`, `web/fetch`, `moderations`, `search`) called the plain, cache-only selector, so an account whose cache entry was never populated (e.g. its first request landed on one of these routes) could be selected even at 0% quota remaining. Those 14 call sites now go through `getProviderCredentialsWithQuotaPreflight()` instead, matching chat/codex coverage. Regression guard: `tests/unit/issue-6686-quota-preflight-coverage.test.ts` (static check that none of the routes call the plain selector anymore + a behavioral check that the preflight-aware selector blocks a 100%-used account). ([#6742](https://github.com/diegosouzapw/OmniRoute/pull/6742))
- **fix(resilience):** account selection could pick an account already out of quota upstream on every credentialed route except `chat`/`codex` ([#6686](https://github.com/diegosouzapw/OmniRoute/issues/6686)) — `getProviderCredentials()` (`src/sse/services/auth.ts`) only skips a connection when a _local cache_ already flags it exhausted (`isQuotaExhaustedForRequest`/`src/domain/quotaCache.ts`); it never itself calls the registered upstream `QuotaFetcher`. Only `getProviderCredentialsWithQuotaPreflight()` performs that live upstream check, and it was wired into exactly 2 call sites (`src/sse/handlers/chat.ts`, `src/app/api/internal/codex-responses-ws/route.ts`) — every other credentialed route (`rerank`, `images/generations`, `images/edits`, `audio/transcriptions|speech|translations`, `videos/generations`, `music/generations`, `ocr`, `providers/[provider]/embeddings`, `providers/[provider]/images/generations`, `web/fetch`, `moderations`, `search`) called the plain, cache-only selector, so an account whose cache entry was never populated (e.g. its first request landed on one of these routes) could be selected even at 0% quota remaining. Those 14 call sites now go through `getProviderCredentialsWithQuotaPreflight()` instead, matching chat/codex coverage. Regression guard: `tests/unit/issue-6686-quota-preflight-coverage.test.ts` (static check that none of the routes call the plain selector anymore + a behavioral check that the preflight-aware selector blocks a 100%-used account). ([#6742](https://github.com/diegosouzapw/OmniRoute/pull/6742))
- **fix(api):** `reasoning_content` (extended-thinking text) was silently dropped from `/v1/chat/completions` SSE on the `claude-web` and `v0-vercel-web` executors ([#6662](https://github.com/diegosouzapw/OmniRoute/issues/6662)) — every chunk builder in both adapters hardcoded `delta: { content: ... }` with no reasoning path, unlike the established pattern already used by `default.ts`/`deepseek-web.ts`/`bedrock.ts` and the real-Anthropic-API `claude-to-openai.ts` translator (`thinking_delta``reasoning_content`). `v0-vercel-web.ts` now forwards an upstream `delta.reasoning_content` field (streaming and non-streaming) the same way `deepseek-web.ts` does. `claude-web.ts`'s `buildClaudeStreamingResponse` now maps a `content_block_start`(`type: "thinking"`)/`content_block_delta`(`delta.thinking`) pair onto `delta.reasoning_content`, and `claude-web/payload.ts`'s `transformToClaude()` no longer hardcodes `thinking_mode: "off"` — a new `wantsExtendedThinking()` derives it from the request's `reasoning_effort`/`reasoning.effort`/`thinking.type` signal, so extended thinking can actually be requested. Regression guard: `tests/unit/issue-6662-repro.test.ts` (RED→GREEN for both adapters). ([#6743](https://github.com/diegosouzapw/OmniRoute/pull/6743))
- **fix(api):** the compression config PUT schema now accepts `enableRenderers` for the RTK engine instead of rejecting the documented option (#6703, #6757 — thanks @alltomatos, with an independent duplicate fix from @chirag127 via #6756).
- **fix(api):** raised the provider `apiKey` length cap for cookie-based web providers, whose session-cookie credentials legitimately exceed the previous limit (#6715, #6759 — thanks @alltomatos).
@@ -1682,7 +1779,7 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62
- fix(sse): stop stream readiness from treating a choices-less mid-stream error frame as a successful stream, so combo can fail over instead of returning zero `choices` (#7503)
- Fixed the Codex connection **Test** button always reporting success for ChatGPT-account tokens: the probe used `gpt-5.3-codex`, a codex-only model ChatGPT accounts reject with a 400 — the same status the probe treats as "auth OK", so a bad token was indistinguishable from a good one. It now probes with `gpt-5.5`, a model ChatGPT-account sessions actually support (#7521).
- The Codex account import (`POST /api/oauth/codex/import`) now validates each record's `refresh_token` against OpenAI's OAuth endpoint before persisting the connection: an already-invalidated session (`refresh_token_invalidated` / a dead `auth.json`) is rejected with a clear "run `codex login` again and re-import" message instead of importing as `active` and failing confusingly on first use. Valid tokens import as before, with any rotated tokens applied (#7522).
- The PKCE OAuth start (`/api/oauth/[provider]/start-callback-server`, used by Codex/Windsurf/Devin) now detects when OmniRoute is being driven from a remote host and returns a reverse-tunnel hint (`remoteHost`, `tunnelCommand`, `message`) instead of hanging silently: the callback server binds the *server's* localhost:PORT, so a browser on a different machine would redirect to its own localhost and never complete. Loopback access is unchanged (#7523).
- The PKCE OAuth start (`/api/oauth/[provider]/start-callback-server`, used by Codex/Windsurf/Devin) now detects when OmniRoute is being driven from a remote host and returns a reverse-tunnel hint (`remoteHost`, `tunnelCommand`, `message`) instead of hanging silently: the callback server binds the _server's_ localhost:PORT, so a browser on a different machine would redirect to its own localhost and never complete. Loopback access is unchanged (#7523).
- fix(providers): search providers now expose a static model catalog derived from `searchTypes`, fixing "does not support models listing" 400 for serper-search, brave-search, perplexity-search, exa-search, tavily-search, google-pse-search, youcom-search, searxng-search, zai-search (#7529)
- fix(sse): map `tool_search` to a Chat function tool instead of dropping it during Responses->Chat translation (#7532)
- fix(sse): gate `verbosity`/`prompt_cache_key` on OpenAI destination during Responses->Chat translation, stopping the leak to non-OpenAI upstreams like NVIDIA (#7533)
@@ -1992,6 +2089,7 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62
- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI.
- **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper.
- **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session.
### 📚 Docs
- **docs(quality):** codify retry policy per runner + release-level drift rule (WS5.4/WS5.5) ([#7107](https://github.com/diegosouzapw/OmniRoute/pull/7107))
@@ -2034,6 +2132,7 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62
- **docs(codex):** document session affinity and stream idle for long tasks ([#8709](https://github.com/diegosouzapw/OmniRoute/pull/8709)) — thanks @DinonowDev
- **docs:** replace outdated Polish docs with translation from latest English ([#8823](https://github.com/diegosouzapw/OmniRoute/pull/8823)) — thanks @leszek3737
- **docs:** restore the Polish API_REFERENCE removed by #8823 ([#8831](https://github.com/diegosouzapw/OmniRoute/pull/8831))
### 🧪 Tests & Quality
- **test(build):** derive pack-artifact closures for all npm-shipped entrypoints (#7065 class) ([#7081](https://github.com/diegosouzapw/OmniRoute/pull/7081))
@@ -2051,6 +2150,7 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62
- **test(e2e):** contract test for the full provider journey ([#8444](https://github.com/diegosouzapw/OmniRoute/pull/8444)) — thanks @HoneyTyagii
- **test(sse):** repair two base-red gates on release/v3.8.49 ([#8490](https://github.com/diegosouzapw/OmniRoute/pull/8490)) — thanks @backryun
- **test(context):** isolate context-manager suite from local DATA_DIR ([#8596](https://github.com/diegosouzapw/OmniRoute/pull/8596)) — thanks @DinonowDev
### 🔧 Chores / CI
- **chore(release):** gate the sync-back push on release-green --quick (WS0.3) ([#7083](https://github.com/diegosouzapw/OmniRoute/pull/7083))
@@ -2141,6 +2241,7 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62
- **refactor(sse):** use the shared ApiKeyMetadata in reasoningRouting instead of a local duck-type ([#8643](https://github.com/diegosouzapw/OmniRoute/pull/8643)) — thanks @backryun
- **refactor(sse):** narrow three media-generation result unions ([#8645](https://github.com/diegosouzapw/OmniRoute/pull/8645)) — thanks @backryun
- **refactor(sse):** declare the semantic-cache read path's parameters ([#8646](https://github.com/diegosouzapw/OmniRoute/pull/8646)) — thanks @backryun
### 🔀 Other
- [needs-vps] fix(electron): materialize Turbopack hashed-module symlinks during packaging (#6724, #6594) ([#6794](https://github.com/diegosouzapw/OmniRoute/pull/6794)) — thanks @huohua-dev
@@ -2170,6 +2271,7 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62
- **refactor(antigravity):** align official clients and callable catalog ([#8013](https://github.com/diegosouzapw/OmniRoute/pull/8013)) — thanks @backryun
- **refactor(compression):** extract resolveHeadroomDetail to keep dispatchCompression under the complexity gate ([#8058](https://github.com/diegosouzapw/OmniRoute/pull/8058))
- **deps:** bump next from 16.2.10 to 16.2.11 ([#8235](https://github.com/diegosouzapw/OmniRoute/pull/8235)) — thanks @dependabot[bot]
### 🩹 Direct release-branch fixes (no PR — authorized base-red sweep, 2026-07-18)
- **fix(base-red):** full-suite realignment after the 102-PR merge campaign: two real production fixes (legacy `refresh_token` column healed before its index is created; `shouldSkipCloudSyncInitialization` no longer swaps its `(env, argv)` arguments) plus 13 test files, goldens, provider counts, and env docs realigned to the live-validated behavior of the merged PRs.
@@ -2177,6 +2279,7 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62
- **test(ui):** the `vitest:ui` suite was red across the whole cycle and nobody saw it — the job kept being cancelled by successive pushes, so a blocking gate never ran to completion. Root cause: #7935 instrumented ~180 shared/dashboard components with `next-intl` without updating the tests that mount them. Fixed at the shared setup (`tests/_setup/vitestUiPolyfills.ts`) with a translator backed by the real `en.json`, memoized per namespace so components whose `useCallback`/`useEffect` depend on `t` no longer loop; 15 test files realigned to the real strings. 194→198 files green.
### 📝 Maintenance
- **Merge-train script** (`scripts/release/merge-train.sh`): batch-validates N queued PRs as ONE merged result on the runner box — merges every queued PR into a throwaway worktree cut from the release tip, runs the fast-gates parity suite once, and prints the `--admin` evidence block per PR (merge-gates §7). Replaces O(N²) per-PR CI re-runs in merge-storms. Regression guard: `tests/unit/merge-train-plan.test.ts`. ([#6784](https://github.com/diegosouzapw/OmniRoute/pull/6784))
- **release:** `list-uncovered-commits.mjs` now unions the CHANGELOG scan window with `changelog.d/` fragment refs (filename `<PR>-` prefix + every `#N` in the body), so a commit covered only by a fragment is no longer reported as an uncovered reconciliation gap ([#6857](https://github.com/diegosouzapw/OmniRoute/issues/6857) via [#6878](https://github.com/diegosouzapw/OmniRoute/pull/6878))
- **chore(ci):** stop dependabot from proposing `typescript` majors — `typescript-eslint` pins a hard peer upper bound (`>=4.8.4 <6.1.0`), so a TS 7 bump violates the peer and takes the whole toolchain red at once. #7068 grouped it with 6 harmless dev bumps and blocked all of them. TS majors now migrate intentionally, in their own PR.
@@ -2274,195 +2377,190 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62
- **CI**: promote `test:vitest:ui` to a blocking gate — the suite is 870/870 green again after the WS6.1 triage (#7127), so `continue-on-error` is removed from the vitest job
- chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up)
### 🙌 Contributors
Thanks to everyone whose work landed in v3.8.49:
| Contributor | PRs / Issues |
| --- | --- |
| [@0x2f0](https://github.com/0x2f0) | #8679 |
| [@0xheycat](https://github.com/0xheycat) | #8751 |
| [@adevwithpurpose](https://github.com/adevwithpurpose) | #8123 |
| [@adrianaryaputra](https://github.com/adrianaryaputra) | #7928 |
| [@advane204f](https://github.com/advane204f) | direct commit / report |
| [@Ajeesh25353646](https://github.com/Ajeesh25353646) | #7528 |
| [@allanvb](https://github.com/allanvb) | #8471, #8759, #8814, #8862 |
| [@alltomatos](https://github.com/alltomatos) | #6703, #6715, #6756, #6757, #6759, #6813, #6819, #6821, #7041, #7042, #7164, #7277, #7490, #7492, #7644 |
| [@alvaretto](https://github.com/alvaretto) | #8077, #8161, #8170 |
| [@amitgolan60-coder](https://github.com/amitgolan60-coder) | #8727 |
| [@andrea-kingautomation](https://github.com/andrea-kingautomation) | #7678 |
| [@andrewmunsell](https://github.com/andrewmunsell) | #6774, #6779, #6795 |
| [@AndrianBalanescu](https://github.com/AndrianBalanescu) | #6828, #6829, #7794, #7804, #7810, #7813, #7816, #7891, #8050 |
| [@anhdiepmmk](https://github.com/anhdiepmmk) | direct commit / report |
| [@anndev-69](https://github.com/anndev-69) | direct commit / report |
| [@apoapostolov](https://github.com/apoapostolov) | #8127 |
| [@arpit-jaiswal-dev](https://github.com/arpit-jaiswal-dev) | #7881 |
| [@artickc](https://github.com/artickc) | #6763, #6955, #7204, #7696, #7768, #7896, #7900, #7911, #7930, #7994, #8006, #8159, #8470, #8496, #8511, #8863 |
| [@Arul-](https://github.com/Arul-) | #7878 |
| [@asynx6](https://github.com/asynx6) | direct commit / report |
| [@attid](https://github.com/attid) | #6984 |
| [@backryun](https://github.com/backryun) | #6280, #6675, #6862, #7296, #7314, #7358, #7531, #7687, #7772, #7812, #7866, #7874, #7882, #7914, #8013, #8225, #8226, #8227, #8230, #8266, #8275, #8298, #8464, #8473, #8483, #8485, #8489, #8490, #8498, #8499, #8520, #8525, #8528, #8531, #8533, #8557, #8638, #8639, #8641, #8643, #8644, #8645, #8646, #8647, #8661, #8663, #8665, #8732, #8810, #8811, #8812, #8815, #8816, #8819, #8820, #8824 |
| [@beingshafin](https://github.com/beingshafin) | direct commit / report |
| [@brick30llc-ctrl](https://github.com/brick30llc-ctrl) | #6944 |
| [@brunnolouzada](https://github.com/brunnolouzada) | #8581 |
| [@c4usal](https://github.com/c4usal) | #7627, #8129 |
| [@CahyokPutraDev99](https://github.com/CahyokPutraDev99) | direct commit / report |
| [@Capslockb](https://github.com/Capslockb) | #7892 |
| [@CarmeloCampos](https://github.com/CarmeloCampos) | direct commit / report |
| [@Chewji9875](https://github.com/Chewji9875) | #7545 |
| [@chirag127](https://github.com/chirag127) | #6022, #6593, #6643, #6644, #6646, #6650, #6769, #6771, #6804, #7079, #7520, #8143 |
| [@chitholian](https://github.com/chitholian) | direct commit / report |
| [@chy1211](https://github.com/chy1211) | direct commit / report |
| [@CitrusIce](https://github.com/CitrusIce) | #6937, #6938 |
| [@costaeder](https://github.com/costaeder) | #8628 |
| [@Dan-ex-hub](https://github.com/Dan-ex-hub) | #7743 |
| [@danscMax](https://github.com/danscMax) | #7359, #7511, #7517, #7648, #7656, #7672, #7689 |
| [@dependabot](https://github.com/dependabot) | #7897, #7898, #8235 |
| [@Dingding-leo](https://github.com/Dingding-leo) | #7988, #7989, #8162, #8165, #8167, #8283, #8286, #8287, #8633, #8636, #8642, #8651, #8652, #8667, #8691, #8715, #8730, #8731, #8762, #8763, #8764, #8795 |
| [@DinonowDev](https://github.com/DinonowDev) | #8552, #8559, #8583, #8596, #8615, #8709, #8710, #8716, #8798, #8800, #8802 |
| [@dionisius95](https://github.com/dionisius95) | direct commit / report |
| [@DKotsyuba](https://github.com/DKotsyuba) | #7500 |
| [@dongwook-chan](https://github.com/dongwook-chan) | #7574, #7582, #7704 |
| [@Dragost](https://github.com/Dragost) | #8289 |
| [@Duongkhanhtool](https://github.com/Duongkhanhtool) | direct commit / report |
| [@dvirarad](https://github.com/dvirarad) | #8410 |
| [@ekinnee](https://github.com/ekinnee) | #7601, #7613, #7614, #7662, #7779, #7927, #7932, #7980, #8735 |
| [@enjoyer-hub](https://github.com/enjoyer-hub) | #6647, #7863 |
| [@epsilonode](https://github.com/epsilonode) | #8724 |
| [@evinjohnn](https://github.com/evinjohnn) | direct commit / report |
| [@fajarbossit](https://github.com/fajarbossit) | direct commit / report |
| [@felipeleite](https://github.com/felipeleite) | direct commit / report |
| [@fenix007](https://github.com/fenix007) | #7171, #7399, #8306, #8561 |
| [@FenjuFu](https://github.com/FenjuFu) | #7942 |
| [@floze-the-genius](https://github.com/floze-the-genius) | #7707 |
| [@fontvu](https://github.com/fontvu) | direct commit / report |
| [@fuko2935](https://github.com/fuko2935) | #8726 |
| [@fzrilsh](https://github.com/fzrilsh) | direct commit / report |
| [@gitcommit90](https://github.com/gitcommit90) | #6986 |
| [@glazec](https://github.com/glazec) | #8632 |
| [@growab](https://github.com/growab) | #7062, #7300 |
| [@guanbear](https://github.com/guanbear) | #7028 |
| [@guhcostan](https://github.com/guhcostan) | #8433 |
| [@hartmark](https://github.com/hartmark) | #8208, #8209, #8210, #8211, #8212, #8213, #8337, #8341, #8354, #8462 |
| [@HassiyYT](https://github.com/HassiyYT) | #7864 |
| [@heishen6](https://github.com/heishen6) | direct commit / report |
| [@herjarsa](https://github.com/herjarsa) | #7612, #7625, #7633, #7869, #7871, #8476 |
| [@HoneyTyagii](https://github.com/HoneyTyagii) | #8444 |
| [@HouMinXi](https://github.com/HouMinXi) | #7035, #7129, #7290, #7398, #7408, #7973, #8290, #8312, #8842, #8845, #8860, #8865 |
| [@hppsc1215](https://github.com/hppsc1215) | #7546, #8835 |
| [@huohua-dev](https://github.com/huohua-dev) | #6794 |
| [@hydraxman](https://github.com/hydraxman) | #7909 |
| [@iamraydoan](https://github.com/iamraydoan) | #6798 |
| [@ianriizky](https://github.com/ianriizky) | #6072, #6538 |
| [@ikelvingo](https://github.com/ikelvingo) | #8355 |
| [@insoln](https://github.com/insoln) | #7906, #7908, #8041, #8054, #8062 |
| [@irvandikky](https://github.com/irvandikky) | #7695 |
| [@isiahw1](https://github.com/isiahw1) | #7555 |
| [@itiwant](https://github.com/itiwant) | direct commit / report |
| [@janeza2](https://github.com/janeza2) | #6308 |
| [@Jordannst](https://github.com/Jordannst) | direct commit / report |
| [@JoshimOfficial](https://github.com/JoshimOfficial) | #8526 |
| [@justdoGIT](https://github.com/justdoGIT) | #8695 |
| [@JxnLexn](https://github.com/JxnLexn) | #6776, #6993, #7154, #7177, #7269, #7273, #7280, #7281, #7282, #7323, #7360, #7377, #7378, #7379, #7380, #7381, #7419, #7607, #7894, #7905, #7912, #8008, #8009, #8010 |
| [@kamenkadmitry](https://github.com/kamenkadmitry) | #7350, #7425 |
| [@kaon0388v1](https://github.com/kaon0388v1) | #7049 |
| [@KaynXu](https://github.com/KaynXu) | #8284 |
| [@KooshaPari](https://github.com/KooshaPari) | #6611, #6632, #6856, #7008, #7087, #7093, #7128, #7130, #7136, #7315, #7318, #7334, #7336 |
| [@KunN-21](https://github.com/KunN-21) | direct commit / report |
| [@leninejunior](https://github.com/leninejunior) | #8049 |
| [@leszek3737](https://github.com/leszek3737) | #7782, #7807, #8343, #8543, #8823 |
| [@like3213934360-lab](https://github.com/like3213934360-lab) | direct commit / report |
| [@linhdmn](https://github.com/linhdmn) | #8439 |
| [@Long-Feeds](https://github.com/Long-Feeds) | #8011 |
| [@loulanyue](https://github.com/loulanyue) | #7540 |
| [@lucasjustinudin](https://github.com/lucasjustinudin) | direct commit / report |
| [@lunkerchen](https://github.com/lunkerchen) | #8024 |
| [@makcimbx](https://github.com/makcimbx) | #7692, #8171, #8432 |
| [@Mananz90](https://github.com/Mananz90) | #8723 |
| [@marceli1404](https://github.com/marceli1404) | #8570 |
| [@maxmad64bis](https://github.com/maxmad64bis) | #8660 |
| [@megamen32](https://github.com/megamen32) | #7313 |
| [@MichaelYcJo](https://github.com/MichaelYcJo) | #8224 |
| [@mikeiagents](https://github.com/mikeiagents) | #8598 |
| [@MikeTuev](https://github.com/MikeTuev) | #6586 |
| [@mikolaj92](https://github.com/mikolaj92) | #6973 |
| [@MisileLab](https://github.com/MisileLab) | #8566 |
| [@MonteNegroX](https://github.com/MonteNegroX) | #8217 |
| [@Moseyuh333](https://github.com/Moseyuh333) | #7781, #8264 |
| [@MrFadiAi](https://github.com/MrFadiAi) | #7073 |
| [@mrprohack](https://github.com/mrprohack) | direct commit / report |
| [@MumuTW](https://github.com/MumuTW) | #8423, #8425, #8426, #8427, #8428, #8524, #8534, #8544, #8545, #8546, #8547, #8548, #8554, #8582, #8585, #8589, #8592, #8604, #8605, #8612, #8619, #8657, #8690, #8741, #8749 |
| [@mustafa-phd](https://github.com/mustafa-phd) | #7686 |
| [@NBN-N3](https://github.com/NBN-N3) | #8794 |
| [@nguyenha935](https://github.com/nguyenha935) | #7493, #7547, #7552, #7553, #7629, #7935, #8031, #8098, #8233, #8565 |
| [@nguyenphi37](https://github.com/nguyenphi37) | direct commit / report |
| [@not-knope](https://github.com/not-knope) | #8206 |
| [@nramabad](https://github.com/nramabad) | #7926 |
| [@oyi77](https://github.com/oyi77) | #6917, #6918, #6919, #6920, #6921, #6923, #7032, #7045, #7046, #7066, #7070, #7178, #7719, #7744, #7787, #7893, #8219 |
| [@Pitchfork-and-Torch](https://github.com/Pitchfork-and-Torch) | #6747, #6791 |
| [@professional-ALFIE](https://github.com/professional-ALFIE) | #6877 |
| [@Prudhvivuda](https://github.com/Prudhvivuda) | #8032, #8220, #8250, #8309, #8441, #8467, #8488, #8493, #8494, #8495, #8586, #8587, #8606, #8607, #8608, #8611, #8805, #8806 |
| [@QRcode1337](https://github.com/QRcode1337) | #7034 |
| [@quanturbo](https://github.com/quanturbo) | #6780 |
| [@rafaeldrincon](https://github.com/rafaeldrincon) | #8866, #8867 |
| [@rafaumeu](https://github.com/rafaumeu) | #6813, #6979, #6982, #6983, #6987, #6988, #7001, #7808, #7815, #8071, #8113, #8179, #8184, #8185, #8190, #8195, #8196, #8203 |
| [@RaviTharuma](https://github.com/RaviTharuma) | #7852, #7853, #7855, #7862, #7885, #7972, #7978, #8021, #8022, #8023, #8025, #8027, #8030, #8101, #8102, #8124, #8252, #8292, #8296, #8301, #8302, #8303, #8304, #8308 |
| [@RCrushMe](https://github.com/RCrushMe) | #8151, #8378 |
| [@ricatix](https://github.com/ricatix) | direct commit / report |
| [@ridho9](https://github.com/ridho9) | #8310 |
| [@rinseaid](https://github.com/rinseaid) | #8721, #8729, #8821 |
| [@rixzkiye](https://github.com/rixzkiye) | direct commit / report |
| [@rqzbeh](https://github.com/rqzbeh) | #8514, #8515 |
| [@rushsinging](https://github.com/rushsinging) | #7256 |
| [@ryanngit](https://github.com/ryanngit) | direct commit / report |
| [@samir-abis](https://github.com/samir-abis) | direct commit / report |
| [@seanford](https://github.com/seanford) | #8194, #8218, #8232 |
| [@SeaXen](https://github.com/SeaXen) | #7063, #7264, #7294 |
| [@Securiteru](https://github.com/Securiteru) | #7683 |
| [@SemonCat](https://github.com/SemonCat) | direct commit / report |
| [@shixi-li](https://github.com/shixi-li) | #8569 |
| [@SingCJ](https://github.com/SingCJ) | direct commit / report |
| [@skutanjir](https://github.com/skutanjir) | #7865, #7939 |
| [@spacesky-cell](https://github.com/spacesky-cell) | direct commit / report |
| [@SteeleHu](https://github.com/SteeleHu) | #8747 |
| [@sumanxg](https://github.com/sumanxg) | #8837, #8856 |
| [@swingtempo](https://github.com/swingtempo) | #7790, #8349, #8766 |
| [@Tasogarre](https://github.com/Tasogarre) | #7861, #8207 |
| [@techsolutionmta](https://github.com/techsolutionmta) | direct commit / report |
| [@tenshiak](https://github.com/tenshiak) | #7274, #7643 |
| [@terrafirmbot-source](https://github.com/terrafirmbot-source) | #8685 |
| [@thepigdestroyer](https://github.com/thepigdestroyer) | #7497 |
| [@tianrking](https://github.com/tianrking) | #7353 |
| [@tientien17](https://github.com/tientien17) | #7841, #7844, #7925 |
| [@TitoTFP](https://github.com/TitoTFP) | #8838 |
| [@tjengbudi](https://github.com/tjengbudi) | #4009 |
| [@tmone](https://github.com/tmone) | #7806, #7933 |
| [@TrackCrewGalore](https://github.com/TrackCrewGalore) | #6271, #8128 |
| [@trfi](https://github.com/trfi) | direct commit / report |
| [@TuyulSpam](https://github.com/TuyulSpam) | direct commit / report |
| [@ViFigueiredo](https://github.com/ViFigueiredo) | #7301 |
| [@vzts](https://github.com/vzts) | #7390 |
| [@warelik](https://github.com/warelik) | direct commit / report |
| [@way-art](https://github.com/way-art) | direct commit / report |
| [@webmasterarbez](https://github.com/webmasterarbez) | #7504 |
| [@wgordon17](https://github.com/wgordon17) | #8852 |
| [@whale9820](https://github.com/whale9820) | direct commit / report |
| [@Wibias](https://github.com/Wibias) | #7125 |
| [@wilsonicdev](https://github.com/wilsonicdev) | direct commit / report |
| [@Witroch4](https://github.com/Witroch4) | #6753, #6762, #6790, #7901, #7902 |
| [@XCrag](https://github.com/XCrag) | direct commit / report |
| [@xiaoyaner0201](https://github.com/xiaoyaner0201) | #8122, #8261, #8262 |
| [@xier2012](https://github.com/xier2012) | #7036, #7050, #7052, #7053, #7056, #7059, #7060, #7061, #7166, #7299 |
| [@xxue-z](https://github.com/xxue-z) | #7098 |
| [@xz-dev](https://github.com/xz-dev) | #6323, #6330, #6714, #6727, #7004, #7012, #7027, #7673, #7700, #7747, #7776, #7843 |
| [@yidecode](https://github.com/yidecode) | direct commit / report |
| [@yinaoxiong](https://github.com/yinaoxiong) | #6805 |
| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer |
| Contributor | PRs / Issues |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [@0x2f0](https://github.com/0x2f0) | #8679 |
| [@0xheycat](https://github.com/0xheycat) | #8751 |
| [@adevwithpurpose](https://github.com/adevwithpurpose) | #8123 |
| [@adrianaryaputra](https://github.com/adrianaryaputra) | #7928 |
| [@advane204f](https://github.com/advane204f) | direct commit / report |
| [@Ajeesh25353646](https://github.com/Ajeesh25353646) | #7528 |
| [@allanvb](https://github.com/allanvb) | #8471, #8759, #8814, #8862 |
| [@alltomatos](https://github.com/alltomatos) | #6703, #6715, #6756, #6757, #6759, #6813, #6819, #6821, #7041, #7042, #7164, #7277, #7490, #7492, #7644 |
| [@alvaretto](https://github.com/alvaretto) | #8077, #8161, #8170 |
| [@amitgolan60-coder](https://github.com/amitgolan60-coder) | #8727 |
| [@andrea-kingautomation](https://github.com/andrea-kingautomation) | #7678 |
| [@andrewmunsell](https://github.com/andrewmunsell) | #6774, #6779, #6795 |
| [@AndrianBalanescu](https://github.com/AndrianBalanescu) | #6828, #6829, #7794, #7804, #7810, #7813, #7816, #7891, #8050 |
| [@anhdiepmmk](https://github.com/anhdiepmmk) | direct commit / report |
| [@anndev-69](https://github.com/anndev-69) | direct commit / report |
| [@apoapostolov](https://github.com/apoapostolov) | #8127 |
| [@arpit-jaiswal-dev](https://github.com/arpit-jaiswal-dev) | #7881 |
| [@artickc](https://github.com/artickc) | #6763, #6955, #7204, #7696, #7768, #7896, #7900, #7911, #7930, #7994, #8006, #8159, #8470, #8496, #8511, #8863 |
| [@Arul-](https://github.com/Arul-) | #7878 |
| [@asynx6](https://github.com/asynx6) | direct commit / report |
| [@attid](https://github.com/attid) | #6984 |
| [@backryun](https://github.com/backryun) | #6280, #6675, #6862, #7296, #7314, #7358, #7531, #7687, #7772, #7812, #7866, #7874, #7882, #7914, #8013, #8225, #8226, #8227, #8230, #8266, #8275, #8298, #8464, #8473, #8483, #8485, #8489, #8490, #8498, #8499, #8520, #8525, #8528, #8531, #8533, #8557, #8638, #8639, #8641, #8643, #8644, #8645, #8646, #8647, #8661, #8663, #8665, #8732, #8810, #8811, #8812, #8815, #8816, #8819, #8820, #8824 |
| [@beingshafin](https://github.com/beingshafin) | direct commit / report |
| [@brick30llc-ctrl](https://github.com/brick30llc-ctrl) | #6944 |
| [@brunnolouzada](https://github.com/brunnolouzada) | #8581 |
| [@c4usal](https://github.com/c4usal) | #7627, #8129 |
| [@CahyokPutraDev99](https://github.com/CahyokPutraDev99) | direct commit / report |
| [@Capslockb](https://github.com/Capslockb) | #7892 |
| [@CarmeloCampos](https://github.com/CarmeloCampos) | direct commit / report |
| [@Chewji9875](https://github.com/Chewji9875) | #7545 |
| [@chirag127](https://github.com/chirag127) | #6022, #6593, #6643, #6644, #6646, #6650, #6769, #6771, #6804, #7079, #7520, #8143 |
| [@chitholian](https://github.com/chitholian) | direct commit / report |
| [@chy1211](https://github.com/chy1211) | direct commit / report |
| [@CitrusIce](https://github.com/CitrusIce) | #6937, #6938 |
| [@costaeder](https://github.com/costaeder) | #8628 |
| [@Dan-ex-hub](https://github.com/Dan-ex-hub) | #7743 |
| [@danscMax](https://github.com/danscMax) | #7359, #7511, #7517, #7648, #7656, #7672, #7689 |
| [@dependabot](https://github.com/dependabot) | #7897, #7898, #8235 |
| [@Dingding-leo](https://github.com/Dingding-leo) | #7988, #7989, #8162, #8165, #8167, #8283, #8286, #8287, #8633, #8636, #8642, #8651, #8652, #8667, #8691, #8715, #8730, #8731, #8762, #8763, #8764, #8795 |
| [@DinonowDev](https://github.com/DinonowDev) | #8552, #8559, #8583, #8596, #8615, #8709, #8710, #8716, #8798, #8800, #8802 |
| [@dionisius95](https://github.com/dionisius95) | direct commit / report |
| [@DKotsyuba](https://github.com/DKotsyuba) | #7500 |
| [@dongwook-chan](https://github.com/dongwook-chan) | #7574, #7582, #7704 |
| [@Dragost](https://github.com/Dragost) | #8289 |
| [@Duongkhanhtool](https://github.com/Duongkhanhtool) | direct commit / report |
| [@dvirarad](https://github.com/dvirarad) | #8410 |
| [@ekinnee](https://github.com/ekinnee) | #7601, #7613, #7614, #7662, #7779, #7927, #7932, #7980, #8735 |
| [@enjoyer-hub](https://github.com/enjoyer-hub) | #6647, #7863 |
| [@epsilonode](https://github.com/epsilonode) | #8724 |
| [@evinjohnn](https://github.com/evinjohnn) | direct commit / report |
| [@fajarbossit](https://github.com/fajarbossit) | direct commit / report |
| [@felipeleite](https://github.com/felipeleite) | direct commit / report |
| [@fenix007](https://github.com/fenix007) | #7171, #7399, #8306, #8561 |
| [@FenjuFu](https://github.com/FenjuFu) | #7942 |
| [@floze-the-genius](https://github.com/floze-the-genius) | #7707 |
| [@fontvu](https://github.com/fontvu) | direct commit / report |
| [@fuko2935](https://github.com/fuko2935) | #8726 |
| [@fzrilsh](https://github.com/fzrilsh) | direct commit / report |
| [@gitcommit90](https://github.com/gitcommit90) | #6986 |
| [@glazec](https://github.com/glazec) | #8632 |
| [@growab](https://github.com/growab) | #7062, #7300 |
| [@guanbear](https://github.com/guanbear) | #7028 |
| [@guhcostan](https://github.com/guhcostan) | #8433 |
| [@hartmark](https://github.com/hartmark) | #8208, #8209, #8210, #8211, #8212, #8213, #8337, #8341, #8354, #8462 |
| [@HassiyYT](https://github.com/HassiyYT) | #7864 |
| [@heishen6](https://github.com/heishen6) | direct commit / report |
| [@herjarsa](https://github.com/herjarsa) | #7612, #7625, #7633, #7869, #7871, #8476 |
| [@HoneyTyagii](https://github.com/HoneyTyagii) | #8444 |
| [@HouMinXi](https://github.com/HouMinXi) | #7035, #7129, #7290, #7398, #7408, #7973, #8290, #8312, #8842, #8845, #8860, #8865 |
| [@hppsc1215](https://github.com/hppsc1215) | #7546, #8835 |
| [@huohua-dev](https://github.com/huohua-dev) | #6794 |
| [@hydraxman](https://github.com/hydraxman) | #7909 |
| [@iamraydoan](https://github.com/iamraydoan) | #6798 |
| [@ianriizky](https://github.com/ianriizky) | #6072, #6538 |
| [@ikelvingo](https://github.com/ikelvingo) | #8355 |
| [@insoln](https://github.com/insoln) | #7906, #7908, #8041, #8054, #8062 |
| [@irvandikky](https://github.com/irvandikky) | #7695 |
| [@isiahw1](https://github.com/isiahw1) | #7555 |
| [@itiwant](https://github.com/itiwant) | direct commit / report |
| [@janeza2](https://github.com/janeza2) | #6308 |
| [@Jordannst](https://github.com/Jordannst) | direct commit / report |
| [@JoshimOfficial](https://github.com/JoshimOfficial) | #8526 |
| [@justdoGIT](https://github.com/justdoGIT) | #8695 |
| [@JxnLexn](https://github.com/JxnLexn) | #6776, #6993, #7154, #7177, #7269, #7273, #7280, #7281, #7282, #7323, #7360, #7377, #7378, #7379, #7380, #7381, #7419, #7607, #7894, #7905, #7912, #8008, #8009, #8010 |
| [@kamenkadmitry](https://github.com/kamenkadmitry) | #7350, #7425 |
| [@kaon0388v1](https://github.com/kaon0388v1) | #7049 |
| [@KaynXu](https://github.com/KaynXu) | #8284 |
| [@KooshaPari](https://github.com/KooshaPari) | #6611, #6632, #6856, #7008, #7087, #7093, #7128, #7130, #7136, #7315, #7318, #7334, #7336 |
| [@KunN-21](https://github.com/KunN-21) | direct commit / report |
| [@leninejunior](https://github.com/leninejunior) | #8049 |
| [@leszek3737](https://github.com/leszek3737) | #7782, #7807, #8343, #8543, #8823 |
| [@like3213934360-lab](https://github.com/like3213934360-lab) | direct commit / report |
| [@linhdmn](https://github.com/linhdmn) | #8439 |
| [@Long-Feeds](https://github.com/Long-Feeds) | #8011 |
| [@loulanyue](https://github.com/loulanyue) | #7540 |
| [@lucasjustinudin](https://github.com/lucasjustinudin) | direct commit / report |
| [@lunkerchen](https://github.com/lunkerchen) | #8024 |
| [@makcimbx](https://github.com/makcimbx) | #7692, #8171, #8432 |
| [@Mananz90](https://github.com/Mananz90) | #8723 |
| [@marceli1404](https://github.com/marceli1404) | #8570 |
| [@maxmad64bis](https://github.com/maxmad64bis) | #8660 |
| [@megamen32](https://github.com/megamen32) | #7313 |
| [@MichaelYcJo](https://github.com/MichaelYcJo) | #8224 |
| [@mikeiagents](https://github.com/mikeiagents) | #8598 |
| [@MikeTuev](https://github.com/MikeTuev) | #6586 |
| [@mikolaj92](https://github.com/mikolaj92) | #6973 |
| [@MisileLab](https://github.com/MisileLab) | #8566 |
| [@MonteNegroX](https://github.com/MonteNegroX) | #8217 |
| [@Moseyuh333](https://github.com/Moseyuh333) | #7781, #8264 |
| [@MrFadiAi](https://github.com/MrFadiAi) | #7073 |
| [@mrprohack](https://github.com/mrprohack) | direct commit / report |
| [@MumuTW](https://github.com/MumuTW) | #8423, #8425, #8426, #8427, #8428, #8524, #8534, #8544, #8545, #8546, #8547, #8548, #8554, #8582, #8585, #8589, #8592, #8604, #8605, #8612, #8619, #8657, #8690, #8741, #8749 |
| [@mustafa-phd](https://github.com/mustafa-phd) | #7686 |
| [@NBN-N3](https://github.com/NBN-N3) | #8794 |
| [@nguyenha935](https://github.com/nguyenha935) | #7493, #7547, #7552, #7553, #7629, #7935, #8031, #8098, #8233, #8565 |
| [@nguyenphi37](https://github.com/nguyenphi37) | direct commit / report |
| [@not-knope](https://github.com/not-knope) | #8206 |
| [@nramabad](https://github.com/nramabad) | #7926 |
| [@oyi77](https://github.com/oyi77) | #6917, #6918, #6919, #6920, #6921, #6923, #7032, #7045, #7046, #7066, #7070, #7178, #7719, #7744, #7787, #7893, #8219 |
| [@Pitchfork-and-Torch](https://github.com/Pitchfork-and-Torch) | #6747, #6791 |
| [@professional-ALFIE](https://github.com/professional-ALFIE) | #6877 |
| [@Prudhvivuda](https://github.com/Prudhvivuda) | #8032, #8220, #8250, #8309, #8441, #8467, #8488, #8493, #8494, #8495, #8586, #8587, #8606, #8607, #8608, #8611, #8805, #8806 |
| [@QRcode1337](https://github.com/QRcode1337) | #7034 |
| [@quanturbo](https://github.com/quanturbo) | #6780 |
| [@rafaeldrincon](https://github.com/rafaeldrincon) | #8866, #8867 |
| [@rafaumeu](https://github.com/rafaumeu) | #6813, #6979, #6982, #6983, #6987, #6988, #7001, #7808, #7815, #8071, #8113, #8179, #8184, #8185, #8190, #8195, #8196, #8203 |
| [@RaviTharuma](https://github.com/RaviTharuma) | #7852, #7853, #7855, #7862, #7885, #7972, #7978, #8021, #8022, #8023, #8025, #8027, #8030, #8101, #8102, #8124, #8252, #8292, #8296, #8301, #8302, #8303, #8304, #8308 |
| [@RCrushMe](https://github.com/RCrushMe) | #8151, #8378 |
| [@ricatix](https://github.com/ricatix) | direct commit / report |
| [@ridho9](https://github.com/ridho9) | #8310 |
| [@rinseaid](https://github.com/rinseaid) | #8721, #8729, #8821 |
| [@rixzkiye](https://github.com/rixzkiye) | direct commit / report |
| [@rqzbeh](https://github.com/rqzbeh) | #8514, #8515 |
| [@rushsinging](https://github.com/rushsinging) | #7256 |
| [@ryanngit](https://github.com/ryanngit) | direct commit / report |
| [@samir-abis](https://github.com/samir-abis) | direct commit / report |
| [@seanford](https://github.com/seanford) | #8194, #8218, #8232 |
| [@SeaXen](https://github.com/SeaXen) | #7063, #7264, #7294 |
| [@Securiteru](https://github.com/Securiteru) | #7683 |
| [@SemonCat](https://github.com/SemonCat) | direct commit / report |
| [@shixi-li](https://github.com/shixi-li) | #8569 |
| [@SingCJ](https://github.com/SingCJ) | direct commit / report |
| [@skutanjir](https://github.com/skutanjir) | #7865, #7939 |
| [@spacesky-cell](https://github.com/spacesky-cell) | direct commit / report |
| [@SteeleHu](https://github.com/SteeleHu) | #8747 |
| [@sumanxg](https://github.com/sumanxg) | #8837, #8856 |
| [@swingtempo](https://github.com/swingtempo) | #7790, #8349, #8766 |
| [@Tasogarre](https://github.com/Tasogarre) | #7861, #8207 |
| [@techsolutionmta](https://github.com/techsolutionmta) | direct commit / report |
| [@tenshiak](https://github.com/tenshiak) | #7274, #7643 |
| [@terrafirmbot-source](https://github.com/terrafirmbot-source) | #8685 |
| [@thepigdestroyer](https://github.com/thepigdestroyer) | #7497 |
| [@tianrking](https://github.com/tianrking) | #7353 |
| [@tientien17](https://github.com/tientien17) | #7841, #7844, #7925 |
| [@TitoTFP](https://github.com/TitoTFP) | #8838 |
| [@tjengbudi](https://github.com/tjengbudi) | #4009 |
| [@tmone](https://github.com/tmone) | #7806, #7933 |
| [@TrackCrewGalore](https://github.com/TrackCrewGalore) | #6271, #8128 |
| [@trfi](https://github.com/trfi) | direct commit / report |
| [@TuyulSpam](https://github.com/TuyulSpam) | direct commit / report |
| [@ViFigueiredo](https://github.com/ViFigueiredo) | #7301 |
| [@vzts](https://github.com/vzts) | #7390 |
| [@warelik](https://github.com/warelik) | direct commit / report |
| [@way-art](https://github.com/way-art) | direct commit / report |
| [@webmasterarbez](https://github.com/webmasterarbez) | #7504 |
| [@wgordon17](https://github.com/wgordon17) | #8852 |
| [@whale9820](https://github.com/whale9820) | direct commit / report |
| [@Wibias](https://github.com/Wibias) | #7125 |
| [@wilsonicdev](https://github.com/wilsonicdev) | direct commit / report |
| [@Witroch4](https://github.com/Witroch4) | #6753, #6762, #6790, #7901, #7902 |
| [@XCrag](https://github.com/XCrag) | direct commit / report |
| [@xiaoyaner0201](https://github.com/xiaoyaner0201) | #8122, #8261, #8262 |
| [@xier2012](https://github.com/xier2012) | #7036, #7050, #7052, #7053, #7056, #7059, #7060, #7061, #7166, #7299 |
| [@xxue-z](https://github.com/xxue-z) | #7098 |
| [@xz-dev](https://github.com/xz-dev) | #6323, #6330, #6714, #6727, #7004, #7012, #7027, #7673, #7700, #7747, #7776, #7843 |
| [@yidecode](https://github.com/yidecode) | direct commit / report |
| [@yinaoxiong](https://github.com/yinaoxiong) | #6805 |
| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer |
---

View File

@@ -63,8 +63,8 @@
| | v3.8.49 | **v3.8.50** | `v3.8.51+` |
| ------------------------- | :-----: | :---------: | :---------: |
| 🌐 Providers | 291 | **339** | more queued |
| 🧠 Documented models | 500+ | **1200+** | — |
| 🌐 Providers | 290 | **339** | more queued |
| 🧠 Documented models | 1185 | **1202** | — |
| 🖼️ Modality Bridge | — | 🆕 vision | video |
| 📡 Radar free catalog | — | — | 🔭 next |
| ⚖️ Quota-aware scheduling | — | — | 🔭 next |
@@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \
</div>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 339 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 339 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 1595%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 40+ free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 105 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 339 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 339 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 1595%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 105 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
<br/>
<br/>
@@ -449,7 +449,7 @@ All **19** strategies — mix & match per combo step:
### 🧱 Resilience is built in (3 independent layers)
<img src="./docs/diagrams/resilience-layers.svg" width="100%" alt="OmniRoute resilience — 3 independent self-healing layers, the right layer for the right failure. Layer 1 provider circuit breaker (whole provider): trips only on 408/5xx, thresholds OAuth 3× / API-key 5× / local 2×, resets 60s/30s/15s into a HALF-OPEN probe, lazy recovery; while OPEN the combo reroutes to the next provider. Layer 2 connection cooldown (one key/account): base 5s OAuth / 3s API-key, exponential ×2 backoff with anti-thundering-herd guard, 429 honors Retry-After, success clears all error state; one cooling key is skipped while sibling keys keep serving. Layer 3 model lockout (one model): per-model 429, local 404 or mode denials lock just that model — never the whole connection. Terminal states (banned, expired, credits exhausted) are for the operator, not cooldowns."/>
<img src="./docs/diagrams/resilience-layers.svg" width="100%" alt="OmniRoute resilience — 3 independent self-healing layers, the right layer for the right failure. Layer 1 provider circuit breaker (whole provider): trips only on 408/5xx, thresholds OAuth 10× / API-key 15× / local 2×, resets 60s/30s/15s into a HALF-OPEN probe, lazy recovery; while OPEN the combo reroutes to the next provider. Layer 2 connection cooldown (one key/account): base 5s OAuth / 3s API-key, exponential ×2 backoff with anti-thundering-herd guard, 429 honors Retry-After, success clears all error state; one cooling key is skipped while sibling keys keep serving. Layer 3 model lockout (one model): per-model 429, local 404 or mode denials lock just that model — never the whole connection. Terminal states (banned, expired, credits exhausted) are for the operator, not cooldowns."/>
<sub>📖 [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) · [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)</sub>
@@ -603,7 +603,7 @@ Pix copia-e-cola:
</div>
> The most complete catalog of any open-source router: **339 providers**, **90+ with a free tier**, **40+ free forever**.
> The most complete catalog of any open-source router: **339 providers**, **90+ with a free tier**, **56 free forever**.
<div align="center">
@@ -1061,7 +1061,7 @@ same process on one port, so there is no separate CLI-only package today.
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>&gt;=22.22.2 &lt;23 || &gt;=24.0.0 &lt;27</code></td></tr>
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 95 domain modules, 145 migrations</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 117 domain modules, 145 migrations</td></tr>
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>

View File

@@ -1 +0,0 @@
- fix(sse): bridge generic openai-compatible/anthropic-compatible provider type ids to their concrete uuid node id in credential lookup (#10085)

View File

@@ -0,0 +1 @@
- 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/**`)

View File

@@ -330,32 +330,75 @@ excludeMarkers, defaultRetryAfterMs}`), matched via `applyStatusRestatement()`.
Permanent errors (agentrouter's `无权访问模型` — no access to this model) are
NEVER restated: `excludeMarkers` vetoes the rule even when `textMarkers` hit,
so the error keeps its original status and nothing retries it forever. A
separate provider classification rule
(`agentrouter-model-access-denied` in `open-sse/config/providerErrorRules.ts`)
declares an `auth_error`/scope-`model` match for this text, but it does not
fire on the live production path today: the rule only matches `status ===
403`, and `checkFallbackError`'s apikey-category `FORBIDDEN` branch
(`open-sse/services/accountFallback.ts`) returns early for a plain 403
*before* the provider-rule lookup ever runs. In practice a `无权访问模型` 403
is handled the same way as the base apikey-provider 403 path (see Connection
Cooldown, §2), not as a 6h model lockout. The rule still exists as a
declarative classification consumable by future callers of `classifyError`
with context — wiring it into the production `checkFallbackError` path is
tracked as a follow-up, not yet done.
so the error keeps its original status and nothing retries it forever. The
matching provider classification rule
(`agentrouter-model-access-denied` in `open-sse/config/providerErrorRules.ts`:
`reason: "auth_error"`, `scope: "model"`, a `6h` declared base cooldown) is
consulted by `checkFallbackError` (`open-sse/services/accountFallback.ts`)
*before* the generic apikey-category `FORBIDDEN` early-return, gated on
`honorsRuleLockScope(provider)` (#10334 — currently agentrouter-exclusive via
the `HONORS_RULE_LOCK_SCOPE_PROVIDERS` allowlist in
`providerErrorRules.ts`). The rule's declared 6h cooldown flows through as
`fallbackResult.baseCooldownMs`, but it still feeds the pre-existing
per-model-quota lockout path (`lockModelIfPerModelQuota()` /
`recordModelLockoutFailure()`, unchanged by #10334 except for the cooldown
source): it is clamped down to the operator's `mlSettings.maxCooldownMs`
(default `1_800_000ms` / 30min), like every other model lockout, and the
*persisted lockout reason* stays the pre-existing hardcoded `"forbidden"`,
not the rule's `"auth_error"` — only the cooldown duration is honored
end-to-end, not the reason string. The connection itself stays active;
sibling models on the same connection are unaffected.
Restated quota errors (`额度不足`) do reach a provider rule in production
(`agentrouter-user-quota-exhausted`, scope `"connection"`), but `scope` on
`ProviderErrorRuleMatch` is currently informational — the persistence path
(`checkFallbackError``combo.ts`) only consumes `reason` and `cooldownMs`,
never `scope`. What actually happens for agentrouter (`passthroughModels:
true``hasPerModelQuota()` returns `true`) is a **per-model** lockout via
`recordModelLockoutFailure()`: the connection itself is never cooled down for
this error (`combo.ts` skips `recordProviderCooldown` for 429 when
`hasPerModelQuota` is true), so other models on the same account keep being
tried — each one burns one call and its own lockout before combo routing
moves on. Honoring `scope` end-to-end (so a `"connection"` match actually
locks the connection) is tracked as a follow-up.
Restated quota errors (`额度不足`) reach a provider rule in production
(`agentrouter-user-quota-exhausted`: `reason: "quota_exhausted"`, `scope:
"connection"`, no declared cooldown of its own — the persistence layer's
scaled backoff default applies). Since #10334, `scope` on
`ProviderErrorRuleMatch` IS consumed end-to-end, but **only** for providers in
the `HONORS_RULE_LOCK_SCOPE_PROVIDERS` allowlist (`providerErrorRules.ts`
today only `"agentrouter"`, gated via `honorsRuleLockScope()`). For every
other provider `scope` remains informational, exactly as before #10334.
`checkFallbackError` surfaces the matched rule's scope as
`fallbackResult.ruleScope`; `isAgentrouterConnectionQuotaScope()`
(`src/sse/services/auth.ts`) is the shared guard that confirms a
`ruleScope` is genuinely safe to honor as a connection-wide, self-recovering
signal (scope `"connection"`, reason `quota_exhausted`, never `permanent`,
never `creditsExhausted` — a defense against a future rule pairing scope
`"connection"` with a permanent account state). Two consumers call it:
- **Persistence** (`markAccountUnavailable()`, `src/sse/services/auth.ts`):
instead of falling into the passthrough-provider **per-model** lockout
branch (agentrouter is `passthroughModels: true``hasPerModelQuota()`
returns `true`), it applies a **temporary connection cooldown**
`testStatus: "unavailable"` + `rateLimitedUntil`, never a terminal status
(`credits_exhausted`/`banned`/`expired`) — so the connection self-recovers
once the cooldown lapses instead of requiring a manual credential reset.
Skipped for connections with `disableCooling: true` (#2997): that opt-out
falls through to the per-model lockout instead (a documented trade-off —
see the code comment above the branch).
- **Same-request combo routing** (`applyComboTargetExhaustion()`,
`open-sse/services/combo/targetExhaustion.ts`): the same guard marks the
connection into the in-memory `exhaustedConnections` set, keyed
`${provider}:${connectionId}`. This only skips a remaining SAME-REQUEST
target that *itself already carries that exact `connectionId`* on its own
target object (`getExhaustedTargetSkipReason()`,
`open-sse/services/combo/comboPredicates.ts`, `if (provider &&
connectionId)` before the `exhaustedConnections` lookup) — a plain
model-list combo, where sibling targets carry no pinned `connectionId` of
their own and one is only resolved per-dispatch from the response's
`X-OmniRoute-Selected-Connection-Id` header, never hits that key match. For
that common case, the real protection against a remaining leg reusing the
just-exhausted account is NOT this Set — it is the persistence layer above
(the connection's `rateLimitedUntil` is now in the future) combined with
this same guard suppressing `transientRateLimitedProviders` for the
failure (see "Two-stage design" and the code comment on the
`isAgentrouterConnectionQuotaScope` branch in `targetExhaustion.ts`): with
that Set left unmarked, `combo.ts`'s `allowRateLimitedConnection` force-allow
(`open-sse/services/combo.ts:1005-1013`, `:2734-2738`) does NOT kick in for
the provider's remaining legs, so credential selection's `rateLimitedUntil`
filter (`src/sse/services/auth.ts:1238`) is honored normally and a
remaining leg either picks a different, still-eligible agentrouter
connection or fails with no credentials available — it does not force its
way back onto the connection this branch just cooled down.
### Two-stage design: status restatement, then classification
@@ -380,6 +423,15 @@ allowlisted providers, the structured error otherwise. Adding a provider to
that the default path for every provider not on the list stays
byte-for-byte unchanged.
A rule's `scope` (`model` / `provider` / `connection`) is a separate opt-in
from `FULL_TEXT_RULE_PROVIDERS`: `checkFallbackError` only surfaces it as
`fallbackResult.ruleScope`, and downstream consumers only honor it as
anything other than an informational label, for providers in the
`HONORS_RULE_LOCK_SCOPE_PROVIDERS` allowlist in the same file (`gated via
honorsRuleLockScope()` — today only `"agentrouter"`). See "Restated quota
errors" above for what a `scope: "connection"` match actually does once a
provider is on that allowlist.
### Adding a new quota-misstating gateway
1. Register one rule array in `statusRestatementRegistry`
@@ -395,7 +447,15 @@ byte-for-byte unchanged.
`checkFallbackError` only ever hands the rule the structured
`{code, type}` error and a body-text rule will never match live traffic.
Rules that match purely on `status`/`headers` (like Opencode's or
Minimax's) do not need this opt-in.
Minimax's) do not need this opt-in. Separately, if the rule declares
`scope: "connection"` and the intent is an actual connection-wide cooldown
plus same-request combo skip (not just an informational label), add the
provider id to `HONORS_RULE_LOCK_SCOPE_PROVIDERS` in the same file — this
is what gates `isAgentrouterConnectionQuotaScope()`-style consumption in
`markAccountUnavailable()` (`src/sse/services/auth.ts`) and
`applyComboTargetExhaustion()`
(`open-sse/services/combo/targetExhaustion.ts`); without it, `scope`
still flows through `fallbackResult.ruleScope` but nothing acts on it.
3. Add unit tests mirroring `tests/unit/upstream-status-restatement.test.ts`
and `tests/unit/agentrouter-error-rules.test.ts` (including the
not-permanent / not-creditsExhausted guards, and — if the provider needs

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint, 339 providers — never stop building, OmniRoute picks the cheapest one that works. Six pillars. Never hit limits: auto-fallback across 339 providers in milliseconds, quota out means the next provider takes over with zero downtime. Save up to 95 percent of tokens: RTK plus Caveman stacked compression cuts 15 to 95 percent of eligible tokens, about 89 percent average on tool-heavy sessions. Zero dollars to start: 90+ providers with a free tier, 40+ free forever — Qoder, Pollinations, Cloudflare, SiliconFlow — no card needed. Every tool works: 33 coding agents including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation — point any tool at /v1 and it just works. Production-grade: circuit breakers, TLS stealth, MCP with 105 tools, A2A, memory, guardrails, evals — 25,000+ tests.">
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint, 339 providers — never stop building, OmniRoute picks the cheapest one that works. Six pillars. Never hit limits: auto-fallback across 339 providers in milliseconds, quota out means the next provider takes over with zero downtime. Save up to 95 percent of tokens: RTK plus Caveman stacked compression cuts 15 to 95 percent of eligible tokens, about 89 percent average on tool-heavy sessions. Zero dollars to start: 90+ providers with a free tier, 56 free forever — Qoder, Pollinations, Cloudflare, SiliconFlow — no card needed. Every tool works: 33 coding agents including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation — point any tool at /v1 and it just works. Production-grade: circuit breakers, TLS stealth, MCP with 105 tools, A2A, memory, guardrails, evals — 25,000+ tests.">
<desc>Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle.</desc>
<defs>
<pattern id="gridPaperP" width="32" height="32" patternUnits="userSpaceOnUse">
@@ -73,7 +73,7 @@
<circle cx="6.6" cy="6.6" r="1.4" fill="#fdcb6e" stroke="none"/>
</g>
<text x="862" y="170" font-size="18" font-weight="800" fill="#fdcb6e">$0 to start</text>
<text x="826" y="204" font-size="13.5" fill="#a1a1aa">90+ providers with a free tier, 40+ free</text>
<text x="826" y="204" font-size="13.5" fill="#a1a1aa">90+ providers with a free tier, 56 free</text>
<text x="826" y="226" font-size="13.5" fill="#a1a1aa">forever — Qoder, Pollinations, Cloudflare,</text>
<text x="826" y="248" font-size="13.5" fill="#a1a1aa">SiliconFlow… No card needed.</text>
</g>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -32,7 +32,7 @@ different endpoint families, so all four products remain separate provider IDs.
| Provider family | `global-sg` | `china-beijing` | Wire format |
| ----------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------- | ----------- |
| `alibaba` | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI |
| `bailian-coding-plan` | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1` | `https://coding.dashscope.aliyuncs.com/apps/anthropic/v1` | Anthropic |
| `bailian-coding-plan` | `https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1` | `https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic/v1` | Anthropic |
| `qwen-cloud` | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI |
| `qwen-cloud-token-plan` | `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | OpenAI |

View File

@@ -30,13 +30,15 @@ export type ProviderErrorRule = {
export type ProviderErrorRuleMatch = {
reason: ConfiguredErrorReason;
/**
* Intended lock scope. NOTE: this field is currently INFORMATIONAL — no
* consumer of `getProviderErrorRuleMatch` (checkFallbackError, combo.ts)
* reads `scope` today; only `reason` and `cooldownMs` are consulted. The
* actual lock scope applied at runtime is decided independently by each
* call site (e.g. `hasPerModelQuota()` deciding model- vs connection-level
* lockout). Honoring this field end-to-end is tracked as a follow-up —
* see `docs/architecture/RESILIENCE_GUIDE.md` §7.
* Intended lock scope. #10334: this field is CONSUMED end-to-end only for
* providers in `HONORS_RULE_LOCK_SCOPE_PROVIDERS` (agentrouter-exclusive
* today, gated by `honorsRuleLockScope()`) — for those, `checkFallbackError`
* surfaces it as `ruleScope` on its return value for the persistence layer
* to honor instead of re-deriving scope from `hasPerModelQuota()`. For
* every other provider it remains INFORMATIONAL: `getProviderErrorRuleMatch`
* callers still read only `reason`/`cooldownMs`, and the actual lock scope
* is decided independently by each call site. Widening the allowlist is
* tracked as a follow-up — see `docs/architecture/RESILIENCE_GUIDE.md` §7.
*/
scope: "model" | "provider" | "connection";
/** Optional explicit cooldown; falls back to the existing per-reason defaults. */
@@ -188,31 +190,29 @@ function buildOpenrouterRules(): ProviderErrorRule[] {
// agentrouter.org misstates temporary quota exhaustion as 403/400 with a
// Chinese body. upstreamStatusRestatement.ts rewrites the status to 429
// BEFORE classification, so rules here accept both the raw 403/400 and the
// restated 429 (text is the real discriminator either way). In production,
// the raw 403 path is what actually matters here: checkFallbackError's
// apikey-category FORBIDDEN branch (~line 1699) returns EARLY for a plain
// 403, before these rules are ever consulted — these rules fire on the
// RESTATED 429 (chatCore's upstreamStatusRestatement hook runs first) via
// resolveRuleMatchBody, which is the only path in checkFallbackError that
// hands these rules the full error text instead of just {code, type}.
// restated 429 (text is the real discriminator either way). Both the raw 403
// path AND the restated 429 path reach these rules in production:
// checkFallbackError's `honorsRuleLockScope("agentrouter")` pre-check
// (#10334) consults these rules BEFORE the generic apikey-category FORBIDDEN
// branch, and the restated 429 reaches them via the existing provider-rule
// lookup in the configured-rule branch. Both paths use resolveRuleMatchBody,
// the only mechanism in checkFallbackError that hands agentrouter's rules the
// full error text instead of just {code, type}.
// - "额度不足": account-wide temporary quota → quota_exhausted, scope
// "connection" (mirror of the Opencode account-wide rationale above).
// NOTE: `scope` on ProviderErrorRuleMatch is currently informational —
// checkFallbackError/combo.ts only consume `reason` and `cooldownMs`, not
// `scope`. For agentrouter specifically (passthroughModels: true →
// hasPerModelQuota() is true), this quota_exhausted match actually
// resolves to a PER-MODEL lockout (recordModelLockoutFailure), not a
// connection-wide lockother models on the same account keep being
// tried by combo routing (each burning one call) until they lock out
// individually. Honoring `scope` end-to-end is tracked as a follow-up.
// `scope` on ProviderErrorRuleMatch is CONSUMED for agentrouter (#10334,
// exclusive allowlist via `honorsRuleLockScope`): checkFallbackError
// surfaces it as `ruleScope` on its return value. Whether the persistence
// layer (markAccountUnavailable / combo target exhaustion) actually
// switches from `hasPerModelQuota()`-derived scope to honoring `ruleScope`
// is Tasks 2/3 of #10334 — this task only surfaces the field.
// - "无权访问模型": declares auth_error/scope "model" (intent: lock only the
// model so the connection keeps serving the rest — Model Lockout tier).
// This rule does NOT fire on the production path today: it only matches
// `status === 403`, but checkFallbackError's apikey FORBIDDEN branch
// returns early for a plain 403 before this rule is ever consulted (see
// the note above). A live `无权访问模型` 403 is handled like the base
// apikey-provider 403 today. Wiring this rule into that path is tracked
// as a follow-up.
// This rule now fires on the production 403 path (#10334): the
// `honorsRuleLockScope` pre-check matches it and returns its declared
// reason/cooldown/scope before the generic apikey-FORBIDDEN early-return
// ever runs. A live `无权访问模型` 403 therefore no longer falls through to
// the base apikey-provider 403 handling.
function buildAgentrouterRules(): ProviderErrorRule[] {
const AGENTROUTER_ERROR_STATUSES = new Set([400, 403, 429]);
return [
@@ -231,8 +231,15 @@ function buildAgentrouterRules(): ProviderErrorRule[] {
if (status !== 403) return null;
const text = JSON.stringify(body ?? "").toLowerCase();
if (!text.includes("无权访问模型")) return null;
// 6h: effectively "until the operator fixes the key's model grants",
// without being an unrecoverable terminal state.
// Declares a 6h cooldown, but the effective cooldown is NOT 6h: the
// model-lockout persistence layer (recordModelLockoutFailure, called from
// markAccountUnavailable) clamps every base cooldown — this one included —
// to the configured model-lockout maxCooldownMs, which defaults to
// 1_800_000ms / 30min (src/lib/resilience/modelLockoutSettings.ts,
// DEFAULT_MODEL_LOCKOUT_SETTINGS.maxCooldownMs). So in practice this is
// "locked for ~30min by default (up to 6h if an operator raises the model-
// lockout cap in settings)", not "until the operator fixes the key's model
// grants" — it is a recoverable window, not a real fix-driven unlock.
return { reason: "auth_error", scope: "model", cooldownMs: 6 * 60 * 60 * 1000 };
},
},
@@ -255,6 +262,21 @@ export const providerRuleRegistry = new Map<string, ProviderErrorRule[]>([
["agentrouter", buildAgentrouterRules()],
]);
/**
* Providers whose ProviderErrorRuleMatch.scope is actually CONSUMED at the
* persistence layer (markAccountUnavailable / combo target exhaustion) to pick
* connection-vs-model lock scope. EXCLUSIVE allowlist by owner decision
* (2026-08-14, issue #10334) — deliberately SEPARATE from
* FULL_TEXT_RULE_PROVIDERS: that set controls what body a rule matches against
* (input), this one controls whether the matched scope changes caller behavior
* (output). A provider could need one without the other.
*/
const HONORS_RULE_LOCK_SCOPE_PROVIDERS = new Set(["agentrouter"]);
export function honorsRuleLockScope(provider: string | null | undefined): boolean {
return !!provider && HONORS_RULE_LOCK_SCOPE_PROVIDERS.has(provider.toLowerCase());
}
/**
* Providers whose rules match on the FULL upstream error text.
* checkFallbackError's rule lookup normally passes only the structured

View File

@@ -15,7 +15,11 @@ import {
serviceSupervisorCooldown,
isNimFunctionDegraded,
} from "../config/errorConfig.ts";
import { getProviderErrorRuleMatch, resolveRuleMatchBody } from "../config/providerErrorRules.ts";
import {
getProviderErrorRuleMatch,
resolveRuleMatchBody,
honorsRuleLockScope,
} from "../config/providerErrorRules.ts";
import * as rot from "./rotationConfig.ts";
import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts";
import {
@@ -1458,6 +1462,11 @@ export function checkFallbackError(
/** #6061: the provider-configured cooldown (ms) before backoff scaling, surfaced so the
* caller can persist an explicit reset window instead of the engine's scaled cooldown. */
configuredCooldownMs?: number;
/** #10334 — the matched ProviderErrorRule's declared lock scope, surfaced so the
* persistence layer can honor it instead of re-deriving scope from
* hasPerModelQuota(). Populated ONLY when honorsRuleLockScope(provider) is true;
* always undefined for every other provider, so existing consumers are unaffected. */
ruleScope?: "model" | "provider" | "connection";
} {
// #10360: an executor-result contract violation is OUR bug, not the provider's.
// Retrying reproduces it verbatim, and cooling the connection down (or tripping
@@ -1712,6 +1721,36 @@ export function checkFallbackError(
return { shouldFallback: false, cooldownMs: 0, reason: RateLimitReason.UNKNOWN };
}
// #10334 — agentrouter EXCLUSIVE: consult the provider rules BEFORE the
// apikey-FORBIDDEN early-return below, so a recognized 403 body (e.g.
// "无权访问模型") carries the rule's declared reason/cooldown/scope instead of
// the generic short auth cooldown. Gated on honorsRuleLockScope — for any
// other provider this block is a no-op and the early-return stays identical.
if (status === HTTP_STATUS.FORBIDDEN && provider && honorsRuleLockScope(provider)) {
const forbiddenMatch = getProviderErrorRuleMatch(
provider,
status,
headers,
resolveRuleMatchBody(provider, structuredError ?? null, errorStr)
);
if (forbiddenMatch) {
const scaled = getScaledBaseCooldown(
forbiddenMatch.reason as RateLimitReasonValue,
backoffLevel
);
const ruleCooldownMs = forbiddenMatch.cooldownMs;
return {
shouldFallback: true,
cooldownMs: ruleCooldownMs ?? scaled.cooldownMs,
baseCooldownMs: ruleCooldownMs ?? scaled.baseCooldownMs,
configuredCooldownMs: ruleCooldownMs,
newBackoffLevel: ruleCooldownMs !== undefined ? 0 : scaled.newBackoffLevel,
reason: forbiddenMatch.reason,
ruleScope: forbiddenMatch.scope,
};
}
}
if (
status === HTTP_STATUS.FORBIDDEN &&
provider &&
@@ -1764,6 +1803,8 @@ export function checkFallbackError(
providerMatch?.cooldownMs !== undefined && providerMatch.cooldownMs > 0
? providerMatch.cooldownMs
: undefined;
const ruleScope =
providerMatch && honorsRuleLockScope(provider) ? providerMatch.scope : undefined;
const fallback = buildRetryableFallback(reason);
if (providerCooldownMs !== undefined) {
return {
@@ -1771,9 +1812,10 @@ export function checkFallbackError(
cooldownMs: providerCooldownMs,
baseCooldownMs: providerCooldownMs,
configuredCooldownMs: providerCooldownMs,
ruleScope,
};
}
return fallback;
return { ...fallback, ruleScope };
}
// #6842: non-backoff configured rules (e.g. status_402) previously never
// consulted providerRuleRegistry, so a provider-specific rule (like
@@ -1789,12 +1831,15 @@ export function checkFallbackError(
)
: null;
const cooldownMs = providerMatch?.cooldownMs ?? configuredRule.cooldownMs ?? 0;
const ruleScope =
providerMatch && honorsRuleLockScope(provider) ? providerMatch.scope : undefined;
return {
shouldFallback: true,
cooldownMs,
baseCooldownMs: cooldownMs,
configuredCooldownMs: cooldownMs,
reason: providerMatch?.reason ?? configuredRule.reason ?? RateLimitReason.UNKNOWN,
ruleScope,
};
}

View File

@@ -27,6 +27,10 @@ import {
import { RateLimitReason } from "../../config/constants.ts";
import { isProviderCircuitOpenResult, isRequestScopedUpstreamFailure } from "./comboPredicates.ts";
import { isCloudflareFingerprintRejection } from "../errorClassifier.ts";
// #10334 — agentrouter-exclusive predicate shared with the persistence layer
// (markAccountUnavailable) so the same-request combo skip and the persisted
// connection cooldown agree on exactly which fallbackResult shapes qualify.
import { isAgentrouterConnectionQuotaScope } from "@/sse/services/auth";
import type { ComboLogger, ResolvedComboTarget } from "./types.ts";
// Connection-level failure statuses: the provider connection itself is likely bad (upstream
@@ -60,7 +64,13 @@ export type ComboExhaustionSets = {
export type ApplyComboTargetExhaustionOptions = {
result: { status: number; headers?: Headers | null };
fallbackResult: Parameters<typeof isProviderExhaustedReason>[0];
fallbackResult: Parameters<typeof isProviderExhaustedReason>[0] & {
/** #10334 — agentrouter-exclusive; see isAgentrouterConnectionQuotaScope
* (src/sse/services/auth.ts). Populated only for providers in
* HONORS_RULE_LOCK_SCOPE_PROVIDERS (today: agentrouter only). */
ruleScope?: "model" | "provider" | "connection";
permanent?: boolean;
};
errorText: string;
rawModel: string;
isTokenLimitBreach: boolean;
@@ -86,6 +96,56 @@ export function applyComboTargetExhaustion(
const { result, sets, log, tag, errorText, structuredError } = opts;
const provider = target.provider;
// #10334: agentrouter-exclusive account-wide quota exhaustion ("额度不足")
// must skip remaining SAME-CONNECTION targets within THIS request too, not
// just via the persisted cooldown markAccountUnavailable applies for
// whichever leg runs next. agentrouter is a passthroughModels provider
// (hasPerModelQuota() === true), so without this branch the classification
// below would fall straight through isProviderQuotaExhausted's
// !hasPerModelQuota() guard, and — for the restated-429 case —
// markConnectionLevelExhaustion's connection-level guard (429 is not in
// CONNECTION_LEVEL_ERROR_STATUSES), marking nothing: combo would keep
// burning one upstream call per remaining model of the same exhausted
// account. isAgentrouterConnectionQuotaScope is the same guard
// markAccountUnavailable uses, so both consumers agree on exactly which
// fallbackResult shapes qualify (never a permanent/credits-exhausted
// result, even one carrying ruleScope "connection").
//
// Runs BEFORE the auth-level (401/403) branch below. This is deliberate,
// not incidental: the "额度不足" rule matches statuses {400, 403, 429}
// (buildAgentrouterRules, providerErrorRules.ts), and Task 1's FORBIDDEN
// pre-check (accountFallback.ts ~1729-1751) surfaces `ruleScope:
// "connection"` for a RAW 403 carrying that body too — so this branch can
// also fire on a 403, not just the restated 429. That is safe: for a 403
// this branch and markAuthLevelExhaustion below write the SAME set with
// the SAME `${provider}:${connId}` key and both return `true` — they are
// set-equivalent for agentrouter on that status. The Cloudflare-1010 and
// Alibaba free-tier EXEMPTIONS further down in the 401/403 branch cannot
// apply here regardless of ordering: 1010 is a CDN fingerprint rejection
// agentrouter's own text never carries, and the Alibaba exemption is
// gated on isAlibabaModelStudioProvider(provider), which agentrouter is
// not.
//
// Unlike the connection-level/auth-level branches, this path deliberately
// does NOT fall through to markTransientOrConnectionLevel, so
// sets.transientRateLimitedProviders is NEVER populated for this failure.
// That is required, not just incidental: combo.ts (both dispatchers, see
// the `allowRateLimitedConnection` reads keyed off
// transientRateLimitedProviders) uses that set to force-allow reusing a
// rate-limited CONNECTION for the provider's remaining legs — i.e. it
// bypasses the very `rateLimitedUntil` filter this branch (and Task 2's
// markAccountUnavailable) just set. Marking it here would silently
// re-open the account this branch just cooled down. One secondary
// consequence: a SIBLING agentrouter connection that is merely
// rate-limited (not the one this branch exhausted) will also no longer be
// force-allowed for a later leg on the same provider — a remaining leg
// can now resolve to "no credentials available" instead of retrying a
// rate-limited sibling account, which is the intended, safer outcome.
if (isAgentrouterConnectionQuotaScope(provider, opts.fallbackResult)) {
markAgentrouterConnectionQuotaExhaustion(target, { sets, log, tag });
return true;
}
// #8133/#8137: auth-level failures (401/403) mean that connection's credentials are bad.
// Split out to keep applyComboTargetExhaustion under the complexity ceiling.
// Cloudflare 1010 (a 403 carrying error_code 1010 / browser_signature_banned) is NOT an
@@ -259,6 +319,35 @@ function markAuthLevelExhaustion(
}
}
/**
* #10334: agentrouter-exclusive connection-scope account quota exhaustion. Mirrors
* markAuthLevelExhaustion's connectionId-present/absent split — when the target carries a
* connectionId, only that connection's account is exhausted (sibling agentrouter connections
* for the same user may still have quota); fall back to whole-provider exhaustion only when no
* connectionId is available.
*/
function markAgentrouterConnectionQuotaExhaustion(
target: ResolvedComboTarget,
opts: Pick<ApplyComboTargetExhaustionOptions, "sets" | "log" | "tag">
): void {
const { sets, log, tag } = opts;
const provider = target.provider;
const connId = target.connectionId ?? undefined;
if (connId) {
sets.exhaustedConnections.add(`${provider}:${connId}`);
log.info(
tag,
`Provider ${provider} connection ${connId} account quota exhausted (rule scope=connection) — marking for skip on remaining targets (#10334)`
);
} else {
sets.exhaustedProviders.add(provider as string);
log.info(
tag,
`Provider ${provider} account quota exhausted (rule scope=connection, no connectionId) — marking for skip on remaining targets (#10334)`
);
}
}
/**
* #1731v2: connection-level errors (408/5xx, excluding the OmniRoute circuit-open signal) suggest
* the provider connection itself is bad → skip remaining same-connection (or same-provider, when

View File

@@ -175,10 +175,15 @@ function readCodeFacts() {
"for(const x of (t?.scopes||[]))sc.add(x);",
"const t=computeFreeModelTotals();const cli=Object.values(CLI_TOOLS);",
"const by=(c)=>cli.filter(x=>x.category===c).length;",
// "Free forever" = every provider whose free access renews or needs no key at all.
// one-time-initial (signup credits) and discontinued pools are excluded on purpose.
"const FOREVER=new Set(['recurring-monthly','recurring-daily','recurring-uncapped',",
"'recurring-credit','keyless']);",
"const ff=new Set();for(const m of t.perModel)if(FOREVER.has(m.freeType))ff.add(m.provider);",
'console.log("@@"+JSON.stringify({freeSteady:t.steadyRecurringTokens,',
"freeFirst:t.firstMonthRealisticTokens,freePools:t.poolCount,engines:ENGINE_IDS.length,",
"cliTotal:cli.length,cliCode:by('code'),cliAgent:by('agent'),",
"mcpTools:countUniqueMcpTools(cols),mcpScopes:sc.size,providers:pids.size}));",
"mcpTools:countUniqueMcpTools(cols),mcpScopes:sc.size,providers:pids.size,freeForever:ff.size}));",
].join("");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "docs-counts-"));
try {
@@ -460,6 +465,10 @@ export function buildChecks() {
),
claim(f.mcpScopes, "MCP scopes", { pattern: /(\d+) scopes/gi }, ["README.md", "AGENTS.md"]),
claim(f.cliTotal, "CLI tools", { pattern: /(\d+) tools(?=\s*\(\d+ CLI)/gi }, ["README.md"]),
claim(f.freeForever, "free-forever providers", { pattern: /(\d+) free forever/gi }, [
"README.md",
"docs/diagrams/promise-pillars.svg",
]),
];
})(),
{

View File

@@ -1,5 +1,4 @@
import { randomUUID, createHash } from "crypto";
import { nodeTypeFromId } from "@/lib/db/providerNodeSelect";
import { extractGoogApiKeyHeader } from "./googApiKeyAuth.ts";
import {
getCachedRawProviderConnections,
@@ -46,6 +45,7 @@ import {
} from "@omniroute/open-sse/services/accountFallback.ts";
import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts";
import { COOLDOWN_MS, RateLimitReason } from "@omniroute/open-sse/config/constants.ts";
import { honorsRuleLockScope } from "@omniroute/open-sse/config/providerErrorRules.ts";
import {
preflightQuota,
isQuotaPreflightEnabled,
@@ -968,33 +968,14 @@ async function getProviderSearchPool(provider: string): Promise<string[]> {
const nodeRecord = asRecord(node);
const nodePrefix = typeof nodeRecord.prefix === "string" ? nodeRecord.prefix.trim() : "";
const nodeId = typeof nodeRecord.id === "string" ? nodeRecord.id.trim() : "";
if (!nodeId) continue;
if (!nodePrefix || !nodeId) continue;
if (
nodePrefix &&
(nodePrefix === provider || nodePrefix === canonicalProvider || nodePrefix === canonicalAlias)
nodePrefix === provider ||
nodePrefix === canonicalProvider ||
nodePrefix === canonicalAlias
) {
searchPool.add(nodeId);
}
// #10085: bridge the concrete uuid node id (what the chat path resolves,
// "<generic-type>-<uuid>") to the GENERIC derived type id (what
// resolveProviderNodeForConnection also accepts for connection creation,
// #4421) -- and back. A connection created via the bare generic type
// (e.g. "openai-compatible-chat") must still be found when the chat path
// looks up the concrete node id, and vice versa.
const derivedType = nodeTypeFromId(nodeId);
if (derivedType && derivedType !== nodeId) {
if (nodeId === provider || nodeId === canonicalProvider || nodeId === canonicalAlias) {
searchPool.add(derivedType);
}
if (
derivedType === provider ||
derivedType === canonicalProvider ||
derivedType === canonicalAlias
) {
searchPool.add(nodeId);
}
}
}
} catch {
// Best-effort alias expansion only.
@@ -2001,6 +1982,46 @@ export async function getProviderCredentialsWithQuotaPreflight(
}
}
/**
* #10334 — Guard for the agentrouter-exclusive "connection scope" quota
* cooldown branch in markAccountUnavailable. The "never terminal" invariant of
* that branch is NOT structurally guaranteed by `ruleScope === "connection"`
* alone — it also depends on the provider rule table only ever pairing scope
* "connection" with a genuinely transient reason. Today
* (`buildAgentrouterRules()` in providerErrorRules.ts) that is true: the only
* rule declaring scope "connection" is the quota-exhausted one. But a FUTURE
* agentrouter rule for a permanent account state (e.g. "账号已封禁") — or a 402
* added to `AGENTROUTER_ERROR_STATUSES` with scope "connection", a natural-
* looking choice for an account ban — would otherwise be silently downgraded
* to a transient cooldown here instead of going through
* resolveTerminalConnectionStatus()/auto-disable below. Require the
* reason/permanent/creditsExhausted signals checkFallbackError already
* computes to explicitly confirm "this is quota, not a permanent state"
* before taking the early return.
*
* Exported (not just inlined) so a synthetic permanent/credits-exhausted
* `fallbackResult` can be tested directly — no rule in the table produces
* that combination today, so this predicate is the only way to pin the guard
* without editing the (production) rule table just for a test.
*/
export function isAgentrouterConnectionQuotaScope(
provider: string | null | undefined,
fallbackResult: {
ruleScope?: "model" | "provider" | "connection";
reason?: string;
permanent?: boolean;
creditsExhausted?: boolean;
}
): boolean {
return (
honorsRuleLockScope(provider) &&
fallbackResult.ruleScope === "connection" &&
fallbackResult.reason === RateLimitReason.QUOTA_EXHAUSTED &&
!fallbackResult.permanent &&
!fallbackResult.creditsExhausted
);
}
/** Persist exponential-backoff state for an unavailable provider connection. */
export async function markAccountUnavailable(
connectionId: string,
@@ -2121,6 +2142,53 @@ export async function markAccountUnavailable(
const disableCooling = connProviderSpecificData.disableCooling === true;
const isPerModelQuotaProvider = hasPerModelQuota(provider, model, connectionPassthroughModels);
// #10334 — agentrouter EXCLUSIVE: the matched provider rule declared scope
// "connection" for account-wide quota exhaustion ("额度不足"). agentrouter is
// a passthroughModels provider (isPerModelQuotaProvider === true), so without
// this branch the next `if` would treat it like any other passthrough 429 and
// lock a SINGLE model — leaving combo routing to burn one upstream call per
// remaining model of the same exhausted account. Must run BEFORE that block.
// Deliberately ignores persistUnavailableState/isCombo: for combo the caller
// downgrades persistUnavailableState to false, and the generic path further
// below would then lock per MODEL instead of cooling the connection — exactly
// what this scope must override. NEVER sets a terminal status: this is a
// renewing quota window, not "credits_exhausted"/"banned"/"expired".
//
// The "never terminal" invariant above is NOT structurally guaranteed by
// ruleScope === "connection" alone — see isAgentrouterConnectionQuotaScope's
// doc comment for why (a future permanent-state rule could pair scope
// "connection" with a non-quota reason). That predicate is the actual guard.
const ruleScopeIsConnection = isAgentrouterConnectionQuotaScope(provider, fallbackResult);
// #2997's disableCooling opt-out is respected here (`!disableCooling` below):
// a connection with disableCooling=true skips this branch entirely and falls
// into the per-model-quota block further down, which locks the model for up
// to ~30min (mlSettings.maxCooldownMs) instead of cooling the connection for
// the rule's shorter transient window. That is a deliberate, if counter-
// intuitive, consequence of #2997's scope (opt-out was designed only for the
// CONNECTION-level cooldown, never extended to model lockout) — "opting out
// of cooldown" ends up producing a LONGER effective block for this one rule.
// Not addressed here; flagged for a future #2997 follow-up if it proves to be
// a real operator complaint.
if (ruleScopeIsConnection && provider && !disableCooling) {
const connectionCooldownMs =
fallbackResult.cooldownMs > 0 ? fallbackResult.cooldownMs : COOLDOWN_MS.rateLimit;
await updateProviderConnection(connectionId, {
lastErrorType: fallbackResult.reason || RateLimitReason.QUOTA_EXHAUSTED,
lastError: `Account quota exhausted (${provider})`,
lastErrorAt: new Date().toISOString(),
errorCode: status,
backoffLevel: fallbackResult.newBackoffLevel ?? backoffLevel,
rateLimitedUntil: getUnavailableUntil(connectionCooldownMs),
testStatus: "unavailable",
});
log.info(
"AUTH",
`Connection-scoped cooldown for ${provider}:${connectionId.slice(0, 8)}${status} ${fallbackResult.reason} ${Math.ceil(connectionCooldownMs / 1000)}s (rule scope=connection, overrides per-model lockout)`
);
return { shouldFallback: true, cooldownMs: connectionCooldownMs };
}
const isNvidiaModelGone = provider === "nvidia" && status === 410;
const modelLockoutOptions = { maxCooldownMs: effectiveProviderProfile?.maxCooldownMs };
if (

View File

@@ -64,6 +64,7 @@
"tests/unit/adaptive-admission-runtime.test.ts",
"tests/unit/adobe-firefly.test.ts",
"tests/unit/agentrouter-error-rules.test.ts",
"tests/unit/agentrouter-lock-scope-10334.test.ts",
"tests/unit/alibaba-free-tier-exhaustion.test.ts",
"tests/unit/anthropic-thinking-signature-recovery.test.ts",
"tests/unit/antigravity-429-quota-tdd.test.ts",

View File

@@ -565,8 +565,8 @@
}
},
"url": {
"nonStream": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
"stream": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1"
"nonStream": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1",
"stream": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1"
}
},
"baseten": {

View File

@@ -1,152 +0,0 @@
/**
* #10085 -- a custom openai-compatible provider connection persisted under the
* GENERIC derived type id ("openai-compatible-chat") must still be reachable
* when the chat path looks up the concrete uuid node id
* ("openai-compatible-chat-<uuid>"), and vice versa.
*
* `resolveProviderNodeForConnection` (src/lib/db/providers/nodes.ts, #4421)
* already accepts the bare generic type id when a connection is created via
* `/api/providers`. But `getProviderSearchPool` (src/sse/services/auth.ts)
* only bridged the search pool via a node's `prefix`, never via the generic
* type id <-> concrete node id relationship, so a connection created under
* the generic type id went permanently unreachable from the chat path --
* "No active credentials for provider: openai-compatible-chat-<uuid>", the
* exact error reported in #10085.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-10085-compat-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const nodesDb = await import("../../src/lib/db/providers/nodes.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const auth = await import("../../src/sse/services/auth.ts");
const NODE_PREFIX = "my-compat-10085";
const NODE_ID = `openai-compatible-chat-458d982b-0000-4000-8000-000000000000`;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
async function seedNode() {
await nodesDb.createProviderNode({
id: NODE_ID,
type: "openai-compatible",
name: "My Compat",
prefix: NODE_PREFIX,
apiType: "chat",
baseUrl: "https://example.test/v1",
});
}
test("a connection stored under the GENERIC type id is reachable when chat resolves the uuid node id (#10085)", async () => {
await resetStorage();
await seedNode();
await providersDb.createProviderConnection({
provider: "openai-compatible-chat", // generic type id, NOT the uuid node id
authType: "apikey",
apiKey: "sk-test-10085",
name: "test-compat",
isActive: true,
testStatus: "active",
priority: 1,
providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" },
});
const creds = await auth.getProviderCredentials(NODE_ID);
assert.ok(
creds,
`chat looked up "${NODE_ID}" but the connection is parked under the generic ` +
`"openai-compatible-chat" provider id -- getProviderSearchPool never bridges the ` +
`generic type id to the concrete node id. This matches #10085 exactly.`
);
});
test("the bridge works in the other direction too: a uuid-stored connection is reachable via the generic type id", async () => {
await resetStorage();
await seedNode();
await providersDb.createProviderConnection({
provider: NODE_ID, // concrete uuid node id
authType: "apikey",
apiKey: "sk-test-10085-b",
name: "test-compat-b",
isActive: true,
testStatus: "active",
priority: 1,
providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" },
});
const creds = await auth.getProviderCredentials("openai-compatible-chat");
assert.ok(
creds,
`a connection stored under the uuid node id "${NODE_ID}" must also be reachable via ` +
`a lookup using the bare generic type id "openai-compatible-chat"`
);
});
test("control: a connection stored under the uuid node id is found by a uuid node id lookup", async () => {
await resetStorage();
await seedNode();
await providersDb.createProviderConnection({
provider: NODE_ID,
authType: "apikey",
apiKey: "sk-test-10085-c",
name: "test-compat-c",
isActive: true,
testStatus: "active",
priority: 1,
providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" },
});
assert.ok(await auth.getProviderCredentials(NODE_ID));
});
test("control: a connection stored under the uuid node id is found via prefix lookup", async () => {
await resetStorage();
await seedNode();
await providersDb.createProviderConnection({
provider: NODE_ID,
authType: "apikey",
apiKey: "sk-test-10085-d",
name: "test-compat-d",
isActive: true,
testStatus: "active",
priority: 1,
providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" },
});
assert.ok(await auth.getProviderCredentials(NODE_PREFIX));
});
test("the bridge does not make unrelated generic types findable", async () => {
await resetStorage();
await seedNode();
await providersDb.createProviderConnection({
provider: "openai-compatible-chat",
authType: "apikey",
apiKey: "sk-test-10085-e",
name: "test-compat-e",
isActive: true,
testStatus: "active",
priority: 1,
providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" },
});
// A different generic type (responses, not chat) must stay unrelated.
assert.equal(await auth.getProviderCredentials("openai-compatible-responses"), null);
});

View File

@@ -11,18 +11,17 @@ import assert from "node:assert/strict";
* Status matching accepts both the raw upstream 403 AND the restated 429
* (upstreamStatusRestatement.ts rewrites 403→429 before classification).
*
* IMPORTANT — `scope` above is what the rule DECLARES, not what production
* enforces: `ProviderErrorRuleMatch.scope` is not consumed by
* checkFallbackError/combo.ts today (only `reason`/`cooldownMs` are). For
* agentrouter (passthroughModels: true → hasPerModelQuota() true), the
* quota_exhausted match actually resolves to a PER-MODEL lockout in
* production, not a connection-wide lock — other models on the same account
* keep being tried by combo routing until they lock out individually. And
* the "无权访问模型" rule never reaches production traffic at all today: it
* only matches raw `status === 403`, but checkFallbackError's apikey
* FORBIDDEN branch returns early for a plain 403 before any provider rule is
* consulted (see A7). See `docs/architecture/RESILIENCE_GUIDE.md` §7 for the
* full writeup and the tracked follow-up to honor `scope`.
* #10334 — `ProviderErrorRuleMatch.scope` is now CONSUMED for agentrouter:
* `checkFallbackError` surfaces it as `ruleScope` on its return value (see
* A11/A12 below), and a raw 403 is no longer an early-return dead end for
* this provider — `honorsRuleLockScope("agentrouter")` gates a dedicated
* pre-check that consults the provider rules BEFORE the generic apikey
* FORBIDDEN branch (see A7/A12). This is an EXCLUSIVE allowlist
* (`honorsRuleLockScope`, A14): every other provider's `scope` stays
* declared-but-unconsumed exactly as before (A13). See
* `docs/architecture/RESILIENCE_GUIDE.md` §7 for the full writeup — Tasks 2/3
* of #10334 wire the surfaced `ruleScope` into the persistence layer
* (markAccountUnavailable / combo target exhaustion).
*/
const { providerRuleRegistry, getProviderErrorRuleMatch } = await import(
@@ -53,7 +52,7 @@ test("A3: quota body also matches the raw (pre-restatement) 403", () => {
assert.equal(match.reason, "quota_exhausted");
});
test("A4: 无权访问模型 → auth_error scope model, at the RULE layer only (getProviderErrorRuleMatch directly) — this rule never receives production traffic (see A7): checkFallbackError's apikey FORBIDDEN branch returns early for a plain 403 before reaching this rule", () => {
test("A4: 无权访问模型 → auth_error scope model, at the RULE layer (getProviderErrorRuleMatch directly) — since #10334 this rule DOES receive production traffic for agentrouter via the honorsRuleLockScope pre-check in checkFallbackError (see A12)", () => {
const match = getProviderErrorRuleMatch("agentrouter", 403, {}, {
error: { message: "无权访问模型 claude-sonnet-4" },
});
@@ -86,14 +85,15 @@ test("A6: guard — restated quota error is retryable, never terminal, and now a
});
test("A7: guard — raw 403 quota (hook bypassed) is still not account-deactivation", () => {
// A raw (pre-restatement) 403 never actually reaches the agentrouter provider
// rules in production: checkFallbackError's apikey-category FORBIDDEN branch
// (status === 403 && getProviderCategory(provider) === "apikey") returns
// EARLY via resolveApiKeyForbiddenFallback before the provider-rule lookup
// is ever consulted. In the real pipeline, chatCore's upstreamStatusRestatement
// hook (Task 2) already converts 403→429 before checkFallbackError ever sees
// it, so this early-return path is what a hook-bypassed raw 403 hits — and it
// must still not be misclassified as permanent account deactivation.
// Since #10334, a raw (pre-restatement) 403 for agentrouter DOES reach the
// provider rules: checkFallbackError's honorsRuleLockScope pre-check runs
// BEFORE the generic apikey-category FORBIDDEN branch and matches the
// "额度不足" rule here (reason quota_exhausted, scope connection — see A11).
// In the real pipeline, chatCore's upstreamStatusRestatement hook (Task 2)
// still converts 403→429 before checkFallbackError sees it, so this raw-403
// path is what a hook-bypassed request hits — and it must still not be
// misclassified as permanent account deactivation, regardless of which
// branch (pre-check or the old apikey-FORBIDDEN fallback) ultimately fires.
const result = checkFallbackError(403, "用户额度不足", 0, null, "agentrouter", null);
assert.equal(result.shouldFallback, true);
assert.ok(!result.permanent);
@@ -139,3 +139,39 @@ test("A10: other providers' checkFallbackError behavior is unchanged (exclusivit
assert.equal(result.reason, "rate_limit_exceeded");
assert.equal(result.cooldownMs, 3000);
});
test("A11: checkFallbackError surfaces ruleScope=connection for agentrouter quota", () => {
const result = checkFallbackError(429, "用户额度不足", 0, null, "agentrouter", null);
assert.equal(result.ruleScope, "connection");
assert.equal(result.reason, "quota_exhausted");
assert.ok(!result.permanent);
});
test("A12: checkFallbackError 403 无权访问模型 carries the rule's scope + cooldown", () => {
const result = checkFallbackError(403, "无权访问模型 claude-opus-5", 0, null, "agentrouter", null);
assert.equal(result.ruleScope, "model");
assert.equal(result.reason, "auth_error");
assert.equal(result.baseCooldownMs, 6 * 60 * 60 * 1000);
});
test("A13: exclusivity — ruleScope stays undefined for other providers", () => {
const opencode = checkFallbackError(
429,
'{"error":{"message":"organization_quota_exceeded"}}',
0,
null,
"opencode",
null
);
assert.equal(opencode.ruleScope, undefined);
const openrouter = checkFallbackError(402, "credits exhausted", 0, null, "openrouter", null);
assert.equal(openrouter.ruleScope, undefined);
});
test("A14: honorsRuleLockScope allowlist is agentrouter-only", async () => {
const { honorsRuleLockScope } = await import("../../open-sse/config/providerErrorRules.ts");
assert.equal(honorsRuleLockScope("agentrouter"), true);
assert.equal(honorsRuleLockScope("AgentRouter"), true);
assert.equal(honorsRuleLockScope("opencode"), false);
assert.equal(honorsRuleLockScope(null), false);
});

View File

@@ -0,0 +1,638 @@
// #10334 — agentrouter EXCLUSIVE: markAccountUnavailable must honor the
// provider rule's declared lock scope instead of always deriving it from
// hasPerModelQuota(). agentrouter is a passthroughModels provider, so a
// naive account-wide quota exhaustion ("额度不足") would otherwise be treated
// as a per-model 429 and lock only ONE model, leaving combo routing to burn
// one upstream call per remaining model of the same exhausted account. This
// suite pins the connection-scoped cooldown behavior AND its invariants:
// never a terminal status, must also win when the caller is combo (isCombo),
// must not lock the model, and must be EXCLUSIVE to agentrouter — every other
// passthroughModels/compatible provider keeps today's per-model lockout.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-agentrouter-lock-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const auth = await import("../../src/sse/services/auth.ts");
const accountFallback = await import("../../open-sse/services/accountFallback.ts");
const { applyComboTargetExhaustion } = await import(
"../../open-sse/services/combo/targetExhaustion.ts"
);
const { classifyProviderError } = await import("../../open-sse/services/errorClassifier.ts");
const QUOTA_EXHAUSTED_429 = '{"error":{"message":"账户额度不足,请充值后重试"}}';
const MODEL_ACCESS_DENIED_403 = '{"error":{"message":"无权访问模型 claude-opus-5"}}';
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
async function seedConnection(
provider: string,
overrides: Record<string, unknown> = {}
): Promise<string> {
const conn = await providersDb.createProviderConnection({
provider,
authType: "apikey",
apiKey: `${provider}-key`,
isActive: true,
testStatus: "active",
...overrides,
});
return (conn as Record<string, unknown>).id as string;
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("agentrouter 429 account quota exhausted -> connection cooldown, never terminal", async () => {
await resetStorage();
const connId = await seedConnection("agentrouter");
const result = await auth.markAccountUnavailable(
connId,
429,
QUOTA_EXHAUSTED_429,
"agentrouter",
"claude-opus-5"
);
assert.equal(result.shouldFallback, true);
assert.ok(result.cooldownMs > 0, "connection cooldown must be positive");
const after = await providersDb.getProviderConnectionById(connId);
assert.equal(after.testStatus, "unavailable");
assert.notEqual(after.testStatus, "credits_exhausted");
assert.ok(after.rateLimitedUntil, "connection must carry a rateLimitedUntil");
assert.ok(
new Date(String(after.rateLimitedUntil)).getTime() > Date.now(),
"rateLimitedUntil must be in the future"
);
});
test("agentrouter 429 quota exhausted with isCombo: true still cools the connection (not a model lock)", async () => {
await resetStorage();
const connId = await seedConnection("agentrouter");
const result = await auth.markAccountUnavailable(
connId,
429,
QUOTA_EXHAUSTED_429,
"agentrouter",
"claude-opus-5",
null,
{ isCombo: true, persistUnavailableState: false }
);
assert.equal(result.shouldFallback, true);
assert.ok(result.cooldownMs > 0);
const after = await providersDb.getProviderConnectionById(connId);
assert.equal(after.testStatus, "unavailable");
assert.notEqual(after.testStatus, "credits_exhausted");
assert.ok(after.rateLimitedUntil, "connection must be cooled down even for combo callers");
});
test("agentrouter quota cooldown does NOT lock the model", async () => {
await resetStorage();
const connId = await seedConnection("agentrouter");
await auth.markAccountUnavailable(
connId,
429,
QUOTA_EXHAUSTED_429,
"agentrouter",
"claude-opus-5"
);
const lockout = accountFallback.getModelLockoutInfo("agentrouter", connId, "claude-opus-5");
assert.equal(lockout, null, "connection-scoped quota must not also record a model lockout");
});
test("agentrouter 403 model-access-denied -> model lockout, connection stays active", async () => {
await resetStorage();
const connId = await seedConnection("agentrouter");
const result = await auth.markAccountUnavailable(
connId,
403,
MODEL_ACCESS_DENIED_403,
"agentrouter",
"claude-opus-5"
);
assert.equal(result.shouldFallback, true);
const after = await providersDb.getProviderConnectionById(connId);
assert.equal(after.testStatus, "active");
assert.ok(!after.rateLimitedUntil, "connection must not be rate-limited by a model-scoped rule");
// #3027's existing per-model-quota-provider branch handles this 403 (it is
// unmodified by #10334 except that it now reads the rule's declared
// cooldown via fallbackResult.baseCooldownMs) — the recorded reason stays
// the pre-existing hardcoded "forbidden", not the rule's "auth_error".
const lockout = accountFallback.getModelLockoutInfo("agentrouter", connId, "claude-opus-5");
assert.equal(lockout?.reason, "forbidden");
// The 6h base cooldown declared by the "agentrouter-model-access-denied"
// rule (open-sse/config/providerErrorRules.ts) must flow through as
// fallbackResult.baseCooldownMs instead of the generic
// COOLDOWN_MS.serviceUnavailable (2s) default — it then gets clamped down
// to the model-lockout maxCooldownMs setting (default 1_800_000ms / 30min)
// by recordModelLockoutFailure, same as every other model lockout. What
// this pins is that the rule's cooldown was consulted at all: a plain 2s
// default would be immediately visible as a tiny remainingMs, not ~max.
assert.ok(
lockout && lockout.remainingMs > 1_700_000,
`expected the rule cooldown to be clamped to ~maxCooldownMs (1_800_000ms), got ${lockout?.remainingMs}ms`
);
});
test("exclusivity: ollama-cloud with an equivalent account-wide-looking 429 keeps today's per-model lockout, no connection cooldown", async () => {
await resetStorage();
const connId = await seedConnection("ollama-cloud");
const result = await auth.markAccountUnavailable(
connId,
429,
QUOTA_EXHAUSTED_429,
"ollama-cloud",
"claude-opus-5"
);
assert.equal(result.shouldFallback, true);
const after = await providersDb.getProviderConnectionById(connId);
// ollama-cloud is NOT in the honorsRuleLockScope allowlist: today's
// per-model-quota behavior for a 429 must be unchanged — connection stays
// active, no rateLimitedUntil.
assert.equal(after.testStatus, "active");
assert.ok(!after.rateLimitedUntil, "non-agentrouter providers must not gain connection cooldown");
// Positive assertion, not just the negative: the model lockout must have
// actually been recorded. Without this, a future refactor that stops
// locking anything for these providers would pass this test silently.
const lockout = accountFallback.getModelLockoutInfo("ollama-cloud", connId, "claude-opus-5");
assert.ok(lockout, "expected the pre-existing per-model lockout to be recorded");
});
test("exclusivity: vertex with an equivalent account-wide-looking 429 keeps today's per-model lockout, no connection cooldown", async () => {
await resetStorage();
const connId = await seedConnection("vertex");
const result = await auth.markAccountUnavailable(
connId,
429,
QUOTA_EXHAUSTED_429,
"vertex",
"claude-opus-5"
);
assert.equal(result.shouldFallback, true);
const after = await providersDb.getProviderConnectionById(connId);
assert.equal(after.testStatus, "active");
assert.ok(!after.rateLimitedUntil, "non-agentrouter providers must not gain connection cooldown");
// Positive assertion, not just the negative — see the ollama-cloud case above.
const lockout = accountFallback.getModelLockoutInfo("vertex", connId, "claude-opus-5");
assert.ok(lockout, "expected the pre-existing per-model lockout to be recorded");
});
// ─── Fix round 1 (#10334 review) ───────────────────────────────────────────
// Important finding: the "never terminal" invariant is not structurally
// guaranteed by `ruleScope === "connection"` alone — it depends on the
// provider rule table only ever pairing scope "connection" with a genuinely
// transient reason. isAgentrouterConnectionQuotaScope() is the actual guard;
// pin its predicate directly with synthetic fallbackResult shapes, since no
// rule in the current table produces a permanent/credits-exhausted result
// with scope "connection" (exercising it end-to-end would require editing
// the production rule table just for a test).
test("isAgentrouterConnectionQuotaScope: rejects a permanent rule result even with scope connection", () => {
const permanentConnectionScopeResult = {
ruleScope: "connection" as const,
reason: "auth_error",
permanent: true,
};
assert.equal(
auth.isAgentrouterConnectionQuotaScope("agentrouter", permanentConnectionScopeResult),
false,
"a future permanent-state rule with scope connection must NOT take the transient-cooldown branch"
);
});
test("isAgentrouterConnectionQuotaScope: rejects a credits-exhausted rule result even with scope connection", () => {
const creditsExhaustedConnectionScopeResult = {
ruleScope: "connection" as const,
reason: "quota_exhausted",
creditsExhausted: true,
};
assert.equal(
auth.isAgentrouterConnectionQuotaScope("agentrouter", creditsExhaustedConnectionScopeResult),
false,
"a future credits-exhausted rule with scope connection must NOT take the transient-cooldown branch"
);
});
test("isAgentrouterConnectionQuotaScope: accepts the real quota-exhausted/connection shape", () => {
const quotaConnectionScopeResult = {
ruleScope: "connection" as const,
reason: "quota_exhausted",
};
assert.equal(
auth.isAgentrouterConnectionQuotaScope("agentrouter", quotaConnectionScopeResult),
true,
"today's only connection-scope rule result (quota_exhausted, no permanent/creditsExhausted) must pass"
);
});
test("isAgentrouterConnectionQuotaScope: rejects non-agentrouter providers regardless of shape", () => {
const quotaConnectionScopeResult = {
ruleScope: "connection" as const,
reason: "quota_exhausted",
};
assert.equal(
auth.isAgentrouterConnectionQuotaScope("ollama-cloud", quotaConnectionScopeResult),
false,
"honorsRuleLockScope must still gate every provider outside the agentrouter allowlist"
);
});
// Minor finding: guard the branch's POSITION in markAccountUnavailable. If a
// future refactor moved the branch above the terminal-status guard (~line
// 2023) or the anti-thundering-herd guard (~line 2038), a credits_exhausted
// connection would be silently overwritten, or a live cooldown would be
// shortened — and the 6 tests above would stay green because none of them
// seed a connection with pre-existing terminal/cooldown state.
test("position guard: a connection already credits_exhausted stays terminal through an agentrouter quota 429", async () => {
await resetStorage();
const connId = await seedConnection("agentrouter", { testStatus: "credits_exhausted" });
const result = await auth.markAccountUnavailable(
connId,
429,
QUOTA_EXHAUSTED_429,
"agentrouter",
"claude-opus-5"
);
assert.equal(result.shouldFallback, true);
assert.equal(result.cooldownMs, 0, "terminal-status short-circuit returns cooldownMs 0");
const after = await providersDb.getProviderConnectionById(connId);
assert.equal(
after.testStatus,
"credits_exhausted",
"the connection-scope branch must never overwrite a pre-existing terminal status"
);
});
test("position guard: an existing live cooldown is not shortened by the connection-scope branch", async () => {
await resetStorage();
const futureCooldown = new Date(Date.now() + 10 * 60 * 1000).toISOString();
const connId = await seedConnection("agentrouter", {
testStatus: "unavailable",
rateLimitedUntil: futureCooldown,
});
const result = await auth.markAccountUnavailable(
connId,
429,
QUOTA_EXHAUSTED_429,
"agentrouter",
"claude-opus-5"
);
assert.equal(result.shouldFallback, true);
const after = await providersDb.getProviderConnectionById(connId);
assert.equal(
after.rateLimitedUntil,
futureCooldown,
"the anti-thundering-herd guard must win: an existing live cooldown must not be reset/shortened"
);
});
// Minor finding: disableCooling=true skips the connection-scope branch (the
// `!disableCooling` condition), so the #10334 bug survives for connections
// with that opt-out — they fall into the ~30min per-model lockout instead of
// the shorter connection cooldown. Documented in the block comment above the
// branch; pin the behavior so a future change to the guard is deliberate.
test("disableCooling=true skips the connection-scope branch and falls back to per-model lockout", async () => {
await resetStorage();
const connId = await seedConnection("agentrouter", {
providerSpecificData: { disableCooling: true },
});
const result = await auth.markAccountUnavailable(
connId,
429,
QUOTA_EXHAUSTED_429,
"agentrouter",
"claude-opus-5"
);
assert.equal(result.shouldFallback, true);
const after = await providersDb.getProviderConnectionById(connId);
// Connection is NOT cooled down — disableCooling's documented CONNECTION-
// level opt-out (#2997) is honored.
assert.equal(after.testStatus, "active");
assert.ok(!after.rateLimitedUntil, "disableCooling must keep the connection selectable");
// But the model IS locked out instead (the #10334 bug's exact symptom for
// disableCooling connections — a deliberate, documented trade-off).
const lockout = accountFallback.getModelLockoutInfo("agentrouter", connId, "claude-opus-5");
assert.ok(
lockout,
"expected a per-model lockout when disableCooling bypasses the connection branch"
);
});
// ─── Task 3 (#10334): combo skips the exhausted agentrouter connection
// WITHIN THE SAME REQUEST ──────────────────────────────────────────────────
// The tests above pin markAccountUnavailable's PERSISTED connection cooldown
// — that only protects the NEXT request. applyComboTargetExhaustion (the
// #1731/#1731v2 shared classifier both combo dispatchers call after every
// target's upstream error — open-sse/services/combo/targetExhaustion.ts) is
// what decides whether remaining targets of the CURRENT request are skipped.
// Without a matching gate there, a combo with 5 legs on the same exhausted
// agentrouter account would still burn all 5 upstream calls before the
// persisted cooldown from the tests above ever kicks in.
function comboSets() {
return {
exhaustedProviders: new Set<string>(),
exhaustedConnections: new Set<string>(),
transientRateLimitedProviders: new Set<string>(),
};
}
function comboTarget(overrides: Record<string, unknown> = {}) {
return {
kind: "model",
executionKey: "ek",
modelStr: "agentrouter/claude-opus-5",
provider: "agentrouter",
providerId: null,
connectionId: "conn-agentrouter-1",
...overrides,
} as Parameters<typeof applyComboTargetExhaustion>[0];
}
const comboLog = { info() {}, warn() {}, error() {}, debug() {} };
const comboBaseOpts = {
errorText: QUOTA_EXHAUSTED_429,
rawModel: "claude-opus-5",
isTokenLimitBreach: false,
allAccountsRateLimited: false,
requestScopedFailure: false,
log: comboLog,
tag: "COMBO",
exhaustedLogLevel: "info" as const,
};
// The real shape checkFallbackError surfaces for agentrouter's restated 429
// (open-sse/config/providerErrorRules.ts's "agentrouter-user-quota-exhausted"
// rule: reason "quota_exhausted", scope "connection") — same shape pinned by
// isAgentrouterConnectionQuotaScope's own tests above.
const CONNECTION_SCOPE_FALLBACK_RESULT = {
ruleScope: "connection" as const,
reason: "quota_exhausted",
};
test("combo in-request skip: agentrouter connection-scope quota marks exhaustedConnections (#10334)", () => {
const sets = comboSets();
const exhausted = applyComboTargetExhaustion(comboTarget(), {
...comboBaseOpts,
result: { status: 429 },
fallbackResult: CONNECTION_SCOPE_FALLBACK_RESULT,
sets,
});
assert.equal(
exhausted,
true,
"combo must treat this like an exhausted target — no same-target retry"
);
assert.ok(
sets.exhaustedConnections.has("agentrouter:conn-agentrouter-1"),
"the exhausted account's connection must be marked so remaining same-connection targets are skipped this request"
);
assert.equal(
sets.exhaustedProviders.size,
0,
"must NOT exhaust the whole provider — sibling agentrouter connections keep their own quota"
);
// Important finding (review round 1): unlike markConnectionLevelExhaustion's
// path, this branch must NEVER populate transientRateLimitedProviders. That
// set drives combo.ts's `allowRateLimitedConnection` force-allow
// (open-sse/services/combo.ts:1005-1013 and :2734-2738), which bypasses the
// `rateLimitedUntil` filter in credential selection (src/sse/services/auth.ts:1238)
// for the provider's remaining legs this request. Marking it here would
// silently re-open the very connection Task 2's markAccountUnavailable (and
// this branch) just cooled down.
assert.equal(
sets.transientRateLimitedProviders.size,
0,
"must NOT mark transientRateLimitedProviders — that would force-allow reusing the connection this branch just exhausted"
);
});
test("combo in-request skip: no connectionId falls back to whole-provider exhaustion", () => {
const sets = comboSets();
const exhausted = applyComboTargetExhaustion(comboTarget({ connectionId: null }), {
...comboBaseOpts,
result: { status: 429 },
fallbackResult: CONNECTION_SCOPE_FALLBACK_RESULT,
sets,
});
assert.equal(exhausted, true);
assert.ok(
sets.exhaustedProviders.has("agentrouter"),
"no connectionId to scope to — must fall back to whole-provider, mirroring markAuthLevelExhaustion"
);
assert.equal(sets.exhaustedConnections.size, 0);
});
test("exclusivity: an equivalent connection-scope-shaped result for ollama-cloud marks nothing (#10334 is agentrouter-only)", () => {
const sets = comboSets();
// Synthetic: production never actually produces ruleScope for a
// non-allowlisted provider (honorsRuleLockScope gates it upstream inside
// checkFallbackError) — feeding it here directly proves
// applyComboTargetExhaustion ALSO re-checks the provider via
// isAgentrouterConnectionQuotaScope rather than trusting whatever shape
// it is handed.
const exhausted = applyComboTargetExhaustion(
comboTarget({ provider: "ollama-cloud", connectionId: "conn-ollama-1" }),
{
...comboBaseOpts,
result: { status: 429 },
fallbackResult: CONNECTION_SCOPE_FALLBACK_RESULT,
sets,
}
);
assert.equal(
exhausted,
false,
"ollama-cloud must fall through to today's per-model-quota behavior unchanged"
);
assert.equal(sets.exhaustedConnections.size, 0);
assert.equal(sets.exhaustedProviders.size, 0);
});
test("exclusivity: vertex with the same synthetic connection-scope result marks nothing", () => {
const sets = comboSets();
const exhausted = applyComboTargetExhaustion(
comboTarget({ provider: "vertex", connectionId: "conn-vertex-1" }),
{
...comboBaseOpts,
result: { status: 429 },
fallbackResult: CONNECTION_SCOPE_FALLBACK_RESULT,
sets,
}
);
assert.equal(exhausted, false);
assert.equal(sets.exhaustedConnections.size, 0);
assert.equal(sets.exhaustedProviders.size, 0);
});
test("guard: a permanent agentrouter fallbackResult with scope connection does NOT mark the connection exhausted here either", () => {
const sets = comboSets();
const exhausted = applyComboTargetExhaustion(comboTarget(), {
...comboBaseOpts,
result: { status: 429 },
fallbackResult: { ruleScope: "connection" as const, reason: "auth_error", permanent: true },
sets,
});
assert.equal(exhausted, false);
assert.equal(sets.exhaustedConnections.has("agentrouter:conn-agentrouter-1"), false);
assert.equal(sets.exhaustedProviders.size, 0);
});
test("guard: a credits-exhausted agentrouter fallbackResult with scope connection does NOT mark the connection exhausted here either", () => {
const sets = comboSets();
const exhausted = applyComboTargetExhaustion(comboTarget(), {
...comboBaseOpts,
result: { status: 429 },
fallbackResult: {
ruleScope: "connection" as const,
reason: "quota_exhausted",
creditsExhausted: true,
},
sets,
});
assert.equal(exhausted, false);
assert.equal(sets.exhaustedConnections.has("agentrouter:conn-agentrouter-1"), false);
assert.equal(sets.exhaustedProviders.size, 0);
});
// Minor finding (review round 1): the connection-scope branch is NOT
// 429-only. The "额度不足" rule (buildAgentrouterRules, providerErrorRules.ts)
// matches statuses {400, 403, 429}, and Task 1's FORBIDDEN pre-check
// (accountFallback.ts ~1729-1751, gated on honorsRuleLockScope) surfaces
// `ruleScope: "connection"` for a RAW 403 carrying that body too — before the
// generic apikey FORBIDDEN early-return, and before markAuthLevelExhaustion
// below ever sees it. Pin that a raw 403 with this shape takes the SAME
// connection-scope branch (not markAuthLevelExhaustion) and lands in the SAME
// set with the SAME key — the two paths are set-equivalent for agentrouter on
// this status, so this is not a behavior change, just documenting which
// branch actually runs.
//
// Fix round 2 finding: the Set-content assertions alone (exhausted===true,
// the connection key present, the other two sets empty) do NOT discriminate
// which branch ran — markAuthLevelExhaustion (the 401/403 branch below)
// produces the byte-identical Set effects for a 403 with a connectionId (same
// key, same untouched sibling sets, same `true` return), so deleting the new
// branch entirely would leave this test green. Use a log spy — the one real
// observable difference between the two paths — to prove the NEW branch
// actually fired: its message is tagged `#10334` / "account quota exhausted"
// (markAgentrouterConnectionQuotaExhaustion), never `#8133` / "auth failure"
// (markAuthLevelExhaustion).
function makeLogSpy() {
const calls: { level: string; tag: string; message: string }[] = [];
const record = (level: string) => (tag: string, message: string) => {
calls.push({ level, tag, message });
};
return {
calls,
log: {
info: record("info"),
warn: record("warn"),
error: record("error"),
debug: record("debug"),
},
};
}
test("combo in-request skip: a RAW 403 with connection-scope quota also takes this branch (not markAuthLevelExhaustion)", () => {
const sets = comboSets();
const spy = makeLogSpy();
const exhausted = applyComboTargetExhaustion(comboTarget(), {
...comboBaseOpts,
result: { status: 403 },
fallbackResult: CONNECTION_SCOPE_FALLBACK_RESULT,
sets,
log: spy.log,
});
assert.equal(exhausted, true);
assert.ok(
sets.exhaustedConnections.has("agentrouter:conn-agentrouter-1"),
"a raw 403 carrying ruleScope=connection must exhaust the connection just like the restated-429 case"
);
assert.equal(sets.exhaustedProviders.size, 0);
assert.equal(
sets.transientRateLimitedProviders.size,
0,
"same suppression as the 429 case — must not force-allow reusing this connection"
);
// The discriminant: prove the NEW (#10334) branch emitted the log, not
// markAuthLevelExhaustion's (#8133) — the Set assertions above cannot tell
// the two apart on their own.
assert.equal(spy.calls.length, 1, "exactly one log call expected for this failure");
assert.match(
spy.calls[0].message,
/#10334/,
"must be markAgentrouterConnectionQuotaExhaustion's log line, not markAuthLevelExhaustion's"
);
assert.ok(
/account quota exhausted/.test(spy.calls[0].message),
"must carry the new branch's wording, not markAuthLevelExhaustion's 'auth failure'"
);
assert.doesNotMatch(
spy.calls[0].message,
/#8133/,
"must NOT be markAuthLevelExhaustion's log line"
);
});
// ─── Invariant sentinel ─────────────────────────────────────────────────
// classifyProviderError (open-sse/services/errorClassifier.ts) must NEVER
// classify agentrouter's restated 429 body ("用户额度不足") as quota_exhausted.
// If it ever does, open-sse/handlers/chatCore.ts's providerFailure handling
// (~line 3835-3856) can reach the terminal `else` branch
// (`testStatus: "credits_exhausted"`) for agentrouter whenever
// lockModelIfPerModelQuota does not itself claim the failure — turning a
// transient, self-recovering account-quota window into a connection that
// requires a manual operator reset. agentrouter is an apikey-category
// provider (not oauth), so shouldPreserveQuotaSignalsFor429 in
// errorClassifier.ts returns false for it and the 429 branch falls through
// to RATE_LIMITED instead — pin that this stays true.
test("sentinel: classifyProviderError never returns quota_exhausted for agentrouter's restated 429 body", () => {
const classification = classifyProviderError(429, "用户额度不足", "agentrouter");
assert.notEqual(
classification,
"quota_exhausted",
"a quota_exhausted classification here would route agentrouter's transient account quota into chatCore's terminal credits_exhausted branch (~chatCore.ts:3849)"
);
});

View File

@@ -329,11 +329,35 @@ test("#7307 quality.yml adds an advisory production build for release PR code ch
assert.match(buildJob[0], /needs\.changes\.outputs\.code == 'true'/);
assert.match(buildJob[0], /github\.event\.pull_request\.draft == false/);
assert.match(buildJob[0], /startsWith\(github\.head_ref, 'mergify\/merge-queue\/'\)/);
// FORK PRs ONLY (2026-08-14). build.yml's `Fast Production Build` fires on
// `push: branches: ["**"]` and runs the superset `build:release`, so own-origin branches
// were building twice; a fork's push never reaches this repo, making this their only
// pre-merge build signal — and forks are 72 of the last 100 PRs into release/**.
assert.match(
buildJob[0],
/github\.event\.pull_request\.head\.repo\.full_name == github\.repository/
/github\.event\.pull_request\.head\.repo\.full_name != github\.repository/
);
assert.match(buildJob[0], /fromJSON\('\["self-hosted","omni-release"\]'\) \|\| 'ubuntu-latest'/);
// Runner PINNED to hosted. The self-hosted pool is 2 permanently-busy runners, where this
// job either queued for hours or was killed by cancel-in-progress — ~10-15% of runs ever
// reached a conclusion across 2026-08-13/14. It must NOT go back on the USE_VPS_RUNNER
// switch (other workflows keep that variable).
assert.match(buildJob[0], /\n {4}runs-on: ubuntu-latest\n/);
// Check the DIRECTIVES, not the prose: the comment above legitimately explains why the
// self-hosted pool was abandoned, so a naive /self-hosted/ scan over the whole block would
// match its own rationale.
const buildDirectives = buildJob[0]
.split("\n")
.filter((line) => !/^\s*#/.test(line))
.join("\n");
assert.doesNotMatch(buildDirectives, /self-hosted/);
assert.doesNotMatch(buildDirectives, /USE_VPS_RUNNER/);
// Memory provisioning mirrored from build.yml: --max-old-space-size bounds only V8's heap,
// never Turbopack's native Rust allocation (#6409), so the swapfile is the load-bearing
// half. Dropping either one puts the hosted build back at risk of an OOM.
assert.match(buildJob[0], /fallocate -l 10G \/mnt\/swapfile/);
assert.match(buildJob[0], /swapon \/mnt\/swapfile/);
assert.match(buildJob[0], /NODE_OPTIONS: "--max-old-space-size=12288"/);
assert.match(buildJob[0], /OMNIROUTE_BUILD_MEMORY_MB: "12288"/);
assert.match(buildJob[0], /continue-on-error: true/);
assert.match(buildJob[0], /uses: actions\/checkout@[0-9a-f]{40} # v7/);
assert.match(buildJob[0], /uses: actions\/setup-node@[0-9a-f]{40} # v7/);