Merge remote-tracking branch 'origin/release/v3.8.44' into feat/port-pr-2064-mistral-ocr

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-02 17:36:39 -03:00
105 changed files with 4130 additions and 941 deletions

View File

@@ -541,6 +541,9 @@ the stale-enforcement added in Fase 6A.3.
19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator (e.g. via `AskUserQuestion`) before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation".
20. PII redaction/sanitization is **opt-in — never on by default**. OmniRoute proxies for self-hosted/local LLMs where the operator owns the data, so mutating request/response payloads by default would silently corrupt legitimate traffic. The two data-mutating PII feature flags **MUST** keep `defaultValue: "false"` in `src/shared/constants/featureFlagDefinitions.ts`: `PII_REDACTION_ENABLED` (request-side) and `PII_RESPONSE_SANITIZATION` (response + streaming). All three application points — `src/lib/guardrails/piiMasker.ts` (request guardrail), `src/lib/piiSanitizer.ts` (response), `src/lib/streamingPiiTransform.ts` (SSE) — are gated on these flags; with both off the `pii-masker` guardrail still runs but never mutates payloads (data passes through untouched). Flipping either default to `"true"` requires explicit operator approval. The regression guard is `tests/unit/pii-opt-in-default.test.ts` (asserts both definition defaults + behavioral pass-through). Opt-in is per-operator via env or the settings/DB override (`src/lib/db/featureFlags.ts`), never a silent default. See `docs/security/GUARDRAILS.md`.
21. **Release-freeze — the release branch is frozen to campaign merges while a `/generate-release` is running.** `/generate-release` opens a marker issue labeled `release-freeze` at the start of reconciliation (Phase 0a) and closes it once the release PR squash-merges to `main`. Before merging **any** PR into the active `release/vX.Y.Z` branch, every campaign workflow (`/review-issues`, `/review-prs`, `/implement-features`, `/green-prs`, `/port-upstream-*`) **MUST** check `gh issue list --repo diegosouzapw/OmniRoute --label release-freeze --state open` — if a freeze is active, **HOLD the merge** (leave the PR ready and open; do NOT merge to the release branch), tell the operator, and resume once the freeze lifts. This is a **coordination signal, not a permission lock**: the release captain and the campaign sessions share the `diegosouzapw` identity, so a GitHub branch-protection lock cannot distinguish them — only this honored marker prevents the mid-release commit races that forced full CHANGELOG re-reconciliation in v3.8.40/v3.8.41 (a parallel campaign advanced `release/vX.Y.Z` by 34 commits mid-run). The release captain's own reconciliation/cycle-open pushes are exempt — they _are_ the release. Fixes that must land during a freeze (a homologation finding) follow the post-merge read-only rule: land on `main` first via `fix/release-vX.Y.Z-*`. **⛔ ONLY `/generate-release` may raise a release-freeze, and ONLY at its Phase 0a (start of generating a new version) — lifted at Phase 12c after the squash-merge to `main`.** No campaign, session, or agent may open a `release-freeze` marker at any other time — a freeze is **never** a mid-development coordination tool. If a session ever believes a freeze is genuinely, unavoidably necessary outside the `/generate-release` flow, it **MUST first ask the operator (`diegosouzapw`) in chat, explicitly alert "estou criando um freeze" and get an explicit yes** — never open, extend, or re-open a `release-freeze` autonomously. Conversely, do **not** close/lift an active `/generate-release` freeze to unblock campaign merges: it protects the captain's single clean CI run and auto-lifts at Phase 12c — closing it early re-triggers the exact commit race it prevents. Verify a freeze is legitimate before acting on it: an open `release-freeze` whose title/body references an **OPEN** release PR (`gh pr view <N> --json state`) is the authorized captain freeze — hold, don't touch.
22. **Cross-session safety — this repo is worked by MANY parallel sessions/agents at once; never step on another's in-flight work.** Two absolute bans, both recurring incidents (this rule exists because they keep happening):
- **(a) Never `git stash` / `git stash pop` — ANYWHERE in this repo, including inside an isolated worktree, and including inside any subagent you dispatch.** `git stash` operates on the **shared repository object store**, not the per-worktree working tree — so a stash pushed or popped in one session can silently clobber or resurrect another parallel session's uncommitted changes. This is not hypothetical: 2026-07-02 a `#5923` quotaCache change leaked into the unrelated `#2296` worktree via a global `stash pop`, and the same class reincided through a **subagent**. To compare working changes against a base ref **without** stashing, use `git show <ref>:<path>` or `git diff <ref> -- <path>`; to confirm a typecheck/lint error is pre-existing on the base, inspect the base ref directly (`git show origin/release/vX.Y.Z:<path>`) — never stash your tree away to "get it clean". **Put this ban verbatim in the prompt of every subagent that touches git** (agents don't inherit this file's context — the recurrence was a subagent).
- **(b) Never merge, push, rebase, or force-push a PR / branch / worktree that another session is actively working.** An open PR whose head is a live fix worktree in `.claude/worktrees/` you did **not** create (e.g. `fix-5852`/`fix-5923` carrying fresh commits, even when they share your `diegosouzapw` identity), or any branch another session owns, is **off-limits — HOLD**, and let the owning session merge it. **Before** merging or pushing to any PR you did not create *this* session, run `git worktree list` to check for a matching in-flight worktree and re-check `gh pr view <N> --json state,headRefOid`. Only the owning session merges its own in-flight PR; mid-flight merges race the owner and re-trigger the exact commit/CHANGELOG races Rule #19 and Rule #21 guard against. (Reinforces Rule #19.)
---

View File

@@ -294,7 +294,7 @@
"_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).",
"tests/unit/oauth-providers-config.test.ts": 873,
"tests/unit/perplexity-web.test.ts": 959,
"tests/unit/provider-models-route.test.ts": 1628,
"tests/unit/provider-models-route.test.ts": 1752,
"tests/unit/provider-validation-specialty.test.ts": 2874,
"_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.",
"tests/unit/providers-page-utils.test.ts": 1052,
@@ -313,7 +313,8 @@
"tests/unit/usage-service-hardening.test.ts": 1633,
"tests/unit/vscode-token-routes.test.ts": 1212,
"tests/unit/combo-config.test.ts": 881,
"tests/unit/web-cookie-providers-new.test.ts": 845,
"_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.",
"tests/unit/web-cookie-providers-new.test.ts": 850,
"tests/unit/response-sanitizer.test.ts": 906
},
"_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.",
@@ -355,6 +356,7 @@
"_rebaseline_2026_06_17_4107_pending_reaper": "PR #4107 own growth: usageHistory.ts 854->934 (+80 = orphaned-pending-request reaper — sweepStalePendingRequests() evicts pending details older than 15min + a hard 5000 cap, plus an unref'd 5min sweep timer wired lazily into trackPendingRequest). Fixes an unbounded memory leak where abnormally-terminated requests left payload previews in pendingById forever. Cohesive with the existing pending-request bookkeeping (mirrors the normal removal path: decrement counters + cleanup buckets); not extractable.",
"_rebaseline_2026_06_17_4116_combo_hedge_listener": "combo.ts: +9 lines from #4116 (detach per-target listener from shared hedge abort signal to fix a listener leak). Behavior-preserving cleanup; 5289 -> 5298.",
"_rebaseline_2026_06_20_4355_gpt5x_pro_pricing": "PR #4355 own growth: pricing.ts 1581->1592 (+11 = pure-data pricing rows for openai gpt-5.5-pro + gpt-5.4-pro, closing the $0 gap that tripped the catalog pricing gate after the #4324 sweep added them to the registry; -pro mirrors its base family tier). provider-models-route.test.ts 1616->1618 (+2 = test-only alignment to the intentional opencode-go discovery behavior: owned_by stamp + T39 two-endpoint fail-path fetchCalls). Both are data/test-only; not extractable.",
"_rebaseline_2026_07_02_5899_airforce_v1_discovery": "PR #5904 own growth: provider-models-route.test.ts 1628->1752 (+124 = test-only Rule #18 regression guards for the Api Airforce /v1/v1/models discovery bug (#5899): (a) a baseUrl ending in /v1/chat/completions must probe .../v1/models not the doubled .../v1/v1/models, and the host-guard case http://v1; (b) a REDIRECT_BLOCKED on one candidate must continue to the next endpoint instead of aborting the probe loop. Both guards fail on the pre-fix code. Test-only additions cohesive with the existing provider-models discovery suite (shared seedConnection/callRoute harness); not separately extractable without duplicating the harness.",
"_rebaseline_2026_06_19_4293_codex_spark_scope": "PR #4293 (isolate Codex Spark quota scope) own growth, MEASURED on the actual merged tree (release/v3.8.30 + #4293). Production: auth.ts 2219->2279 (+60) threads requestedModel into Codex quota-policy/headroom/preflight/P2C scoring so normal Codex and GPT-5.3-Codex-Spark windows are evaluated independently; chatCore.ts 5116->5125 (+9) passes the failing model scope into Codex 429 failover (markCodexScopeRateLimited) instead of a connection-wide rateLimitedUntil write; accountFallback.ts 1727->1731 (+4) scopes Codex model-lock keys to codex vs spark. Heavy parsing/display logic lives in new leaf helpers under the cap (codexQuotaScopes.ts, codexUsageQuotas.ts, codexFailover.ts). Tests: account-fallback-service 1544->1569, executor-codex 1336->1339, sse-auth 1527->1553, usage-service-hardening 1612->1633 (added Spark-scope regression coverage). Cohesive wiring at existing selection/failover lockout boundaries; not extractable.",
"_rebaseline_2026_06_20_4447_openai_gpt41mini_o_mini_pricing": "PR #4447 own growth: pricing.ts 1592->1620 (+28 = pure-data pricing rows closing the null/$0 gap for registry-exposed OpenAI ids gpt-4.1-mini, gpt-4.1-nano, o3-mini, o4-mini that tripped the catalog pricing gate; getPricingForModel does an exact lookup, so a missing key resolves to null. Official OpenAI per-1M prices + the table's derived-field convention (reasoning=output*1.5, cache_creation=input, cached=official). Restore-green for a pre-existing release/v3.8.32 red surfaced by #4432's __RUN_ALL__ run. Cohesive data; not extractable.",
"_rebaseline_2026_06_20_web_cookie_validator_shadow_fix": "validation.ts 4518->4522 (+4 = move the generic web-cookie validateWebCookieProvider dispatch from the TOP of validateProviderApiKey to a FALLBACK after SPECIALTY_VALIDATORS, plus a comment, so #4023's generic AUTH_007 ping no longer shadows the rich per-provider validators (grok-web #3474 IP-reputation/Cloudflare, chatgpt-web cf-mitigated, claude/gemini/copilot/qwen/t3-web). Restores provider-validation-specialty.test.ts (112/112) while keeping web-cookie-auth007 (5/5). Behavior fix at an existing dispatch boundary; not extractable.",

View File

@@ -1,7 +1,7 @@
---
title: "Extending the Compression Pipeline"
version: 3.8.40
lastUpdated: 2026-06-28
version: 3.8.44
lastUpdated: 2026-07-02
---
# Extending the Compression Pipeline
@@ -512,6 +512,62 @@ To drive it from config, set `mode: "stacked"` and provide the step array under
---
## Upstream Sync Policy
OmniRoute's compression engines credit several upstream projects in the README
("inspired by RTK, Caveman, LLMLingua-2, Troglodita"). A common contributor
question is: **when upstream RTK adds a new tool filter or Caveman adds a rule
pack, how does that reach OmniRoute?** This section is the authoritative answer.
### Vendored copies vs. independent implementations
| Engine | Relationship to upstream | Location |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| **RTK** | **Independent reimplementation** (inspired-by, not a copy) | `open-sse/services/compression/engines/rtk/` |
| **Caveman** | **Independent reimplementation** (inspired-by) | `open-sse/services/compression/engines/cavemanAdapter.ts` |
| **Headroom** | Mostly internal; only the `gcf/` codec is **genuinely vendored** from `gcf-typescript` (MIT, SPDX-marked, generic profile only) | `open-sse/services/compression/engines/headroom/gcf/` |
| **LLMLingua-2 / Troglodita** | Inspired-by (drive the `llmlingua` + `session-dedup` engines) | `open-sse/services/compression/engines/llmlingua/`, `session-dedup` |
Key point: **RTK and Caveman are clean-room TypeScript implementations of the
_ideas_ (filter rules, rule packs), not vendored source trees.** There is no
upstream copy to `git pull` from — which is exactly why the README says
"inspired by" rather than "bundled".
### How upstream improvements are merged
There is **no automated upstream-release tracking and no `compression-sync`
label** — by design. Because the engines are reimplementations, an upstream RTK
filter or Caveman rule pack is not merged as code; it is **re-expressed as a new
rule/filter in OmniRoute's own format** (see
[COMPRESSION_RULES_FORMAT.md](./COMPRESSION_RULES_FORMAT.md)) and lands ad-hoc via
a normal PR. The extension points above (custom engine, language pack, RTK filter)
are the sanctioned way to contribute one.
Recent examples of exactly this flow:
- RTK filters for Gradle & `dotnet` build output (v3.8.42)
- RTK filters for kubectl / docker-build / composer / gh (#2824)
- Caveman Indonesian language pack (#3975), plus German / French / Japanese / Chinese packs
### Headroom (input-compression proxy)
Headroom is **fully internal** — a pinned vendored `gcf` codec snapshot plus
OmniRoute's own `smartcrusher` / `toon` / `tabular` layers. There is no live
upstream to track beyond the vendored copy; updates to `gcf` are refreshed
manually when the codec changes and re-validated against the compression budget
gate (`check:compression-budget`).
### Proposing an upstream-inspired improvement
1. **Don't vendor** — re-express the upstream rule/filter in OmniRoute's format.
2. Add it via the matching extension point below (language pack, RTK filter, or
custom engine).
3. Reference the upstream project in the PR description (attribution), not by
copying its license-bearing source.
4. Include tests and confirm the `check:compression-budget` gate still passes.
---
## Best Practices
### Engine Development

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -18,7 +18,9 @@ _TBD_
### 📝 Maintenance
_TBD_
- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw)
- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw)
---

View File

@@ -49,6 +49,7 @@ import { groqProvider } from "./registry/groq/index.ts";
import { inference_netProvider } from "./registry/inference-net/index.ts";
import { llm7Provider } from "./registry/llm7/index.ts";
import { cerebrasProvider } from "./registry/cerebras/index.ts";
import { clinepassProvider } from "./registry/clinepass/index.ts";
import { sparkdeskProvider } from "./registry/sparkdesk/index.ts";
import { nlpcloudProvider } from "./registry/nlpcloud/index.ts";
import { nvidiaProvider } from "./registry/nvidia/index.ts";
@@ -220,6 +221,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"inference-net": inference_netProvider,
llm7: llm7Provider,
cerebras: cerebrasProvider,
clinepass: clinepassProvider,
sparkdesk: sparkdeskProvider,
nlpcloud: nlpcloudProvider,
nvidia: nvidiaProvider,

View File

@@ -0,0 +1,42 @@
import type { RegistryEntry } from "../../shared.ts";
// ClinePass — Cline's $9.99/mo BYOK API-key gateway (https://cline.bot). Distinct
// from the OAuth `cline` provider: same host (api.cline.bot) but a plain Bearer
// API key and the `cline-pass/*` model namespace. Responses are wrapped in a
// {success, data} envelope — unwrapped by open-sse/utils/clinepassEnvelope.ts.
export const clinepassProvider: RegistryEntry = {
id: "clinepass",
alias: "clinepass",
format: "openai",
executor: "default",
baseUrl: "https://api.cline.bot/api/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
extraHeaders: {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
},
models: [
{ id: "cline-pass/glm-5.2", name: "GLM-5.2 (ClinePass)" },
{ id: "cline-pass/kimi-k2.7-code", name: "Kimi K2.7 Code (ClinePass)" },
{ id: "cline-pass/kimi-k2.6", name: "Kimi K2.6 (ClinePass)" },
{
id: "cline-pass/deepseek-v4-pro",
name: "DeepSeek V4 Pro (ClinePass)",
supportsReasoning: true,
maxOutputTokens: 50000,
},
{
id: "cline-pass/deepseek-v4-flash",
name: "DeepSeek V4 Flash (ClinePass)",
supportsReasoning: true,
maxOutputTokens: 50000,
},
{ id: "cline-pass/mimo-v2.5", name: "MiMo-V2.5 (ClinePass)" },
{ id: "cline-pass/mimo-v2.5-pro", name: "MiMo-V2.5-Pro (ClinePass)" },
{ id: "cline-pass/minimax-m3", name: "MiniMax M3 (ClinePass)" },
{ id: "cline-pass/qwen3.7-max", name: "Qwen3.7 Max (ClinePass)" },
{ id: "cline-pass/qwen3.7-plus", name: "Qwen3.7 Plus (ClinePass)" },
],
passthroughModels: true,
};

View File

@@ -1,13 +1,16 @@
// Tool-call emulation helpers for the ChatGPT Web executor (#5240).
// Tool-call emulation helpers for web-cookie executors (#5240, #5927).
//
// chatgpt.com has no native function calling. When the OpenAI request carries
// `tools`, the prompt-side shim (`prepareToolMessages` in
// ../translator/webTools.ts) injects a `<tool>` contract; on the response side
// we parse `<tool>{...}</tool>` blocks back into OpenAI `tool_calls` —
// mirroring the sibling web-session executors (qwen-web, perplexity-web, ...).
// Web-cookie providers (chatgpt-web, perplexity-web, ...) have no native
// function calling. When the OpenAI request carries `tools`, the prompt-side
// shim (`prepareToolMessages` in ../translator/webTools.ts) injects a `<tool>`
// contract; on the response side we parse `<tool>{...}</tool>` blocks back
// into OpenAI `tool_calls`.
//
// The whole tool-mode orchestration lives here so the (frozen) chatgpt-web.ts
// only gains an import + a single delegating call.
// The whole tool-mode orchestration lives here — provider-agnostic — so each
// (frozen) executor only gains an import + a single delegating call. Despite
// the filename (kept for git-blame continuity from #5240, the first caller),
// this module is shared: `buildToolModeResponse()` accepts an `idSeed` so
// every provider gets its own `tool_calls[].id` prefix.
import { buildToolAwareResult } from "../translator/webTools.ts";
@@ -28,7 +31,8 @@ function sseChunk(data: unknown): string {
*/
async function applyToolCallsToJsonResponse(
response: Response,
requestedTools: unknown
requestedTools: unknown,
idSeed: string
): Promise<Response> {
const bodyText = await response.text();
try {
@@ -37,7 +41,7 @@ async function applyToolCallsToJsonResponse(
const { content, toolCalls, finishReason } = buildToolAwareResult(
rawContent,
requestedTools,
"cgpt"
idSeed
);
if (toolCalls) {
json.choices[0].message = { role: "assistant", content: null, tool_calls: toolCalls };
@@ -107,9 +111,13 @@ export async function buildToolModeResponse(
bufferedJson: Response,
requestedTools: unknown,
stream: boolean,
meta: { cid: string; created: number; model: string }
meta: { cid: string; created: number; model: string; idSeed?: string }
): Promise<Response> {
const jsonResponse = await applyToolCallsToJsonResponse(bufferedJson, requestedTools);
const jsonResponse = await applyToolCallsToJsonResponse(
bufferedJson,
requestedTools,
meta.idSeed ?? "cgpt"
);
if (!stream) return jsonResponse;
const completion = await jsonResponse.json();
return new Response(toolCompletionToSseStream(completion, meta.cid, meta.created, meta.model), {

View File

@@ -737,9 +737,54 @@ export class DefaultExecutor extends BaseExecutor {
}
}
// ClinePass reasoning models burn all of max_tokens on the thinking phase
// when the budget is too small, leaving content empty (finish_reason:
// "length"). Bump max_tokens to a safe floor when reasoning is enabled and
// the budget is undersized. CLINEPASS-GATED — no-op for every other provider.
if (typeof withDefaults === "object" && withDefaults !== null) {
this.ensureThinkingBudget(withDefaults as Record<string, unknown>, model);
}
return withDefaults;
}
// ClinePass / OpenRouter-style thinking models leave content empty when the
// reasoning budget consumes all of max_tokens. Bump max_tokens to a safe
// minimum only when reasoning is enabled and the budget is undersized.
// CLINEPASS-GATED: returns early for every other provider.
ensureThinkingBudget(body: Record<string, unknown>, model: string): Record<string, unknown> {
if (!body || this.provider !== "clinepass") return body;
const outboundModel = typeof body.model === "string" ? body.model : model;
const entry = getRegistryEntry(this.provider);
const modelEntry = entry?.models?.find((m) => m.id === outboundModel);
if (!modelEntry?.supportsReasoning) return body;
const extraBody = body.extra_body as Record<string, unknown> | undefined;
const thinking = extraBody?.thinking as Record<string, unknown> | undefined;
const effort = body.reasoning_effort;
const reasoningEnabled =
thinking?.type === "enabled" ||
(typeof effort === "string" && effort !== "none" && effort !== "off") ||
effort === true;
if (!reasoningEnabled) return body;
const MIN_TOKENS = 4096;
const maxOutput =
typeof modelEntry.maxOutputTokens === "number" && modelEntry.maxOutputTokens > 0
? modelEntry.maxOutputTokens
: MIN_TOKENS;
const target = Math.min(MIN_TOKENS, maxOutput);
const current = body.max_tokens ?? body.max_completion_tokens;
if (typeof current !== "number" || current <= 0) {
body.max_tokens = target;
} else if (current < MIN_TOKENS && current < maxOutput) {
body.max_tokens = MIN_TOKENS;
}
return body;
}
/**
* Refresh credentials via the centralized tokenRefresh service.
* Delegates to getAccessToken() which handles all providers with

View File

@@ -13,7 +13,8 @@ import {
TlsClientUnavailableError,
type TlsFetchResult,
} from "../services/perplexityTlsClient.ts";
import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts";
import { prepareToolMessages } from "../translator/webTools.ts";
import { buildToolModeResponse } from "./chatgptWebTools.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
const PPLX_SSE_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask";
@@ -965,8 +966,29 @@ export class PerplexityWebExecutor extends BaseExecutor {
const cid = `chatcmpl-pplx-${crypto.randomUUID().slice(0, 12)}`;
const created = Math.floor(Date.now() / 1000);
// Tool mode buffers the full completion (no live token streaming) and
// converts <tool> text into real tool_calls — even when the caller asked
// for a streaming response — mirroring chatgpt-web's toolMode (#5240,
// #5927). Without this, streaming requests (the default for agentic
// coding clients) never emitted a tool_calls SSE delta.
let finalResponse: Response;
if (stream) {
if (hasTools) {
const bufferedJson = await buildNonStreamingResponse(
response.body,
model,
cid,
created,
parsed.history,
parsed.currentMsg,
signal
);
finalResponse = await buildToolModeResponse(bufferedJson, requestedTools, stream, {
cid,
created,
model,
idSeed: "pplx",
});
} else if (stream) {
const sseStream = buildStreamingResponse(
response.body,
model,
@@ -996,31 +1018,6 @@ export class PerplexityWebExecutor extends BaseExecutor {
);
}
if (hasTools && !stream) {
const bodyText = await (finalResponse as Response).text();
try {
const json = JSON.parse(bodyText);
const rawContent = json?.choices?.[0]?.message?.content || "";
const { content, toolCalls, finishReason } = buildToolAwareResult(
rawContent,
requestedTools,
"pplx"
);
if (toolCalls) {
json.choices[0].message = { role: "assistant", content: null, tool_calls: toolCalls };
json.choices[0].finish_reason = finishReason;
} else {
json.choices[0].message.content = content;
}
finalResponse = new Response(JSON.stringify(json), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch {
/* keep original response */
}
}
return {
response: finalResponse,
url: PPLX_SSE_ENDPOINT,

View File

@@ -212,6 +212,7 @@ import {
type NonStreamingSseTerminalState,
} from "./chatCore/nonStreamingSse.ts";
import { parseNonStreamingResponseBody } from "./chatCore/nonStreamingResponseParse.ts";
import { unwrapClinepassEnvelope } from "../utils/clinepassEnvelope.ts";
import { recordNonStreamingUsageStats } from "./chatCore/nonStreamingUsageStats.ts";
import {
createBodyTimeoutError,
@@ -3517,6 +3518,63 @@ export async function handleChatCore({
let responseBody = parsed.responseBody;
let responsePayloadFormat = parsed.responsePayloadFormat;
// ── ClinePass {success,data} envelope unwrap (before translation) ──────────
// ClinePass wraps non-streaming JSON in a {success, data} envelope; errors
// use {success:false, error}. Transient {success:false, error:"empty..."}
// responses get one 2s retry before surfacing. CLINEPASS-GATED — untouched
// for every other provider. Envelope errors route through createErrorResult
// (→ buildErrorBody/sanitizeErrorMessage, Rule #12).
if (provider === "clinepass") {
let { body: unwrapped, error: envError } = unwrapClinepassEnvelope(responseBody, provider);
if (envError && /empty/i.test(envError.message || "")) {
log?.warn?.("RETRY", "clinepass returned empty content, retrying once after 2s");
await new Promise((r) => setTimeout(r, 2000));
try {
const retryResult = await executeProviderRequest(effectiveModel, false);
if (retryResult?.response?.ok) {
const retryParsed = await parseNonStreamingResponseBody({
providerResponse: retryResult.response,
upstreamStream: undefined,
providerHeaders: retryResult.headers,
finalBody: retryResult.transformedBody,
targetFormat,
model,
log,
});
if (retryParsed.kind !== "invalid_sse" && retryParsed.kind !== "invalid_json") {
providerResponse = retryResult.response;
providerUrl = retryResult.url;
providerHeaders = retryResult.headers;
finalBody = providerRequestCapture.body(retryResult.transformedBody);
({ body: unwrapped, error: envError } = unwrapClinepassEnvelope(
retryParsed.responseBody,
provider
));
}
}
} catch (retryErr) {
log?.warn?.(
"RETRY",
`clinepass retry failed: ${
retryErr instanceof Error ? retryErr.message : String(retryErr)
}`
);
}
}
if (envError) {
appendRequestLog({
model,
provider,
connectionId,
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
}).catch(() => {});
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "clinepass_envelope_error");
trackPendingRequest(model, provider, connectionId, false);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, envError.message);
}
responseBody = unwrapped;
}
// Check for empty content response (fake success) - trigger fallback
if (isEmptyContentResponse(responseBody)) {
appendRequestLog({

View File

@@ -173,6 +173,27 @@ async function handleStreamableRequest(request: Request): Promise<Response> {
// terminated/unknown, the server MUST respond with HTTP 404 Not Found so the
// client re-initializes. A 400 here is non-recoverable for spec-compliant
// clients (they only re-init on 404). See issue #5169.
//
// Auto-recovery: if the client sends an initialize request with a stale session
// id (e.g. after a server restart or idle eviction), treat it as a fresh
// initialization rather than hard-failing with 404. This avoids requiring users
// to manually restart their MCP client after every server restart.
if (await isInitializeRequest(request)) {
const newSession = createStreamableSession();
try {
const response = await withMcpHttpAuthContext(request, () =>
newSession.transport.handleRequest(request)
);
return withSessionHeader(response, newSession.sessionId);
} catch (err) {
closeStreamableSession(newSession.sessionId);
console.error("[MCP] Streamable HTTP error during stale-session recovery:", err);
return new Response(JSON.stringify({ error: "MCP transport error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
return errorResponse("Not Found: Unknown Mcp-Session-Id header", -32000, 404);
}

View File

@@ -0,0 +1,76 @@
import { buildClineHeaders } from "@/shared/utils/clineAuth";
// ClinePass live-models resolver. ClinePass is API-key-only (BYOK), but the
// underlying api.cline.bot host also accepts the OAuth `cline` credential shape,
// so the resolver reuses buildClineHeaders() (the shared workos:-prefixed Cline
// header set) for the non-apikey path. Only `cline-pass/*` model ids are kept.
const CLINEPASS_MODELS_ENDPOINT = "https://api.cline.bot/api/v1/models";
const FETCH_TIMEOUT_MS = 5000;
export interface ClinepassModel {
id: string;
name: string;
}
/**
* Filter a raw models list down to the ClinePass namespace (`cline-pass/*`).
* Pure — shared by the live resolver and the discovery-config parseResponse.
*/
export function filterClinepassModels(rawList: unknown): ClinepassModel[] {
if (!Array.isArray(rawList)) return [];
return rawList
.filter(
(m): m is { id: string; name?: string } =>
!!m &&
typeof (m as { id?: unknown }).id === "string" &&
(m as { id: string }).id.startsWith("cline-pass/")
)
.map((m) => ({ id: m.id, name: m.name || m.id }));
}
function buildModelListHeaders(token: string, isApiKey: boolean): Record<string, string> {
if (isApiKey) {
return {
Accept: "application/json",
Authorization: `Bearer ${token}`,
};
}
return buildClineHeaders(token, { Accept: "application/json" });
}
/**
* Resolve the live ClinePass model catalogue for a connection. Returns
* `{ models }` on success or `null` on any failure (missing token, non-2xx,
* bad shape, timeout) so callers fall back to the static registry catalogue.
*/
export async function resolveClinepassModels(credentials: {
apiKey?: string | null;
accessToken?: string | null;
}): Promise<{ models: ClinepassModel[] } | null> {
const isApiKey = Boolean(credentials?.apiKey);
const token = isApiKey ? credentials.apiKey : credentials?.accessToken;
if (!token) return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const headers = buildModelListHeaders(token, isApiKey);
const response = await fetch(CLINEPASS_MODELS_ENDPOINT, {
method: "GET",
headers,
signal: controller.signal,
});
if (!response.ok) return null;
const json = await response.json();
const rawList = Array.isArray(json) ? json : json?.data;
const models = filterClinepassModels(rawList);
return models.length ? { models } : null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}

View File

@@ -48,7 +48,6 @@ import { fetchCodexQuota } from "./codexQuotaFetcher.ts";
import {
evaluateQuotaCutoff,
getQuotaFetcher,
type PreflightQuotaThresholds,
type QuotaInfo,
} from "./quotaPreflight.ts";
import * as semaphore from "./rateLimitSemaphore.ts";
@@ -195,6 +194,10 @@ import {
orderTargetsByHeadroom,
type PreScreenResult,
} from "./combo/quotaStrategies.ts";
import {
buildAutoQuotaThresholds,
resolveQuotaExhaustionCutoffForTarget,
} from "./combo/quotaExhaustionCutoff.ts";
import {
classifyTask,
getConversationCacheKey,
@@ -257,55 +260,6 @@ function clampPercent(value: number): number {
return Math.max(0, Math.min(100, value));
}
function asThresholdMap(value: unknown): Record<string, number> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const result: Record<string, number> = {};
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
const numeric = Number(raw);
if (key && Number.isFinite(numeric)) result[key] = numeric;
}
return result;
}
function quotaWindowLookupNames(provider: string, windowName: string): string[] {
const names = [windowName];
const lower = windowName.toLowerCase();
if (lower !== windowName) names.push(lower);
if (provider === "codex") {
if (lower.includes("session") || lower === "5h" || lower === "five_hour") names.push("session");
if (lower.includes("weekly") || lower === "7d" || lower === "seven_day") names.push("weekly");
if (lower.includes("monthly") || lower === "30d") names.push("monthly");
}
return [...new Set(names)];
}
function buildAutoQuotaThresholds(
provider: string,
connection: Record<string, unknown> | undefined,
resilienceSettings: ResilienceSettings | null | undefined
): PreflightQuotaThresholds {
const quotaPreflight = (resilienceSettings ?? resolveResilienceSettings(null))?.quotaPreflight;
const defaultThresholdPercent = quotaPreflight?.defaultThresholdPercent ?? 2;
const warnThresholdPercent = quotaPreflight?.warnThresholdPercent ?? 20;
const providerWindowMap = asThresholdMap(quotaPreflight?.providerWindowDefaults?.[provider]);
const perConnectionWindowOverrides = asThresholdMap(connection?.quotaWindowThresholds);
return {
resolveMinRemainingPercent: (windowName: string | null): number => {
if (windowName !== null) {
for (const lookupWindowName of quotaWindowLookupNames(provider, windowName)) {
const override = perConnectionWindowOverrides[lookupWindowName];
if (typeof override === "number") return override;
const providerDefault = providerWindowMap[lookupWindowName];
if (typeof providerDefault === "number") return providerDefault;
}
}
return defaultThresholdPercent;
},
resolveWarnRemainingPercent: () => warnThresholdPercent,
};
}
function quotaRemainingPercentFromQuota(quota: unknown): number {
if (!quota || typeof quota !== "object") return 100;
const record = quota as Record<string, unknown>;
@@ -1638,6 +1592,12 @@ export async function handleComboChat({
)
: new Map<string, PreScreenResult>();
// #5923 (Finding #4) — reset-window config for the shared per-target quota-
// exhaustion cutoff below. The "auto" strategy already applies its own cutoff
// via buildAutoCandidates/routableCandidates, so this only affects the other
// 16 strategies (priority, weighted, etc.) that funnel through executeTarget.
const quotaCutoffResetWindowConfig = resolveResetWindowConfig(config as Record<string, unknown>);
if (orderedTargets.length === 0) {
return comboModelNotFoundResponse("Combo has no executable targets");
}
@@ -1790,6 +1750,33 @@ export async function handleComboChat({
return null;
}
// #5923 (Finding #4) — honor the same opt-in quota-exhaustion cutoff the
// "auto" strategy already applies (buildAutoCandidates), for every other
// strategy (priority, weighted, etc.). Strictly scoped per (provider,
// connectionId): a 0%-remaining connection is skipped here, but sibling
// connections/models on the same provider are untouched — the provider
// circuit breaker is never touched by this check. The "auto" strategy is
// excluded to avoid a redundant duplicate fetch — it already filtered its
// candidate pool via `routableCandidates` before reaching this loop.
if (strategy !== "auto" && provider && target.connectionId) {
const quotaCutoff = await resolveQuotaExhaustionCutoffForTarget(
provider,
target.connectionId,
resilienceSettings,
quotaCutoffResetWindowConfig,
combo.name,
log
);
if (quotaCutoff.blocked) {
log.info(
"COMBO",
`Skipping ${modelStr} — quota exhaustion cutoff (${quotaCutoff.reason || "quota_exhausted"})`
);
if (i > 0) fallbackCount++;
return null;
}
}
// Pre-screen snapshot is NOT used as a permanent skip — availability
// is always re-checked via isModelAvailable below because connection
// cooldowns can expire between setTry retries, making a previously

View File

@@ -0,0 +1,140 @@
/**
* Quota-exhaustion cutoff helpers for combo routing.
*
* Home of the opt-in per-(provider, connection, window) quota-exhaustion cutoff
* shared by the "auto" strategy candidate builder (`buildAutoQuotaThresholds`,
* consumed by combo.ts::buildAutoCandidates) and the per-target eligibility loop
* (`resolveQuotaExhaustionCutoffForTarget`, consumed by combo.ts::handleComboChat
* for every non-auto strategy). Extracted from combo.ts (#5923 Finding #4) to
* keep the god-file under its frozen size cap; behavior is byte-identical.
*
* Pure leaf: this module never imports from the combo barrel. Threshold math and
* cutoff evaluation are delegated to ./quotaPreflight.ts; the reset-aware quota
* fetch/cache is delegated to ./quotaStrategies.ts.
*/
import {
evaluateQuotaCutoff,
getQuotaFetcher,
type PreflightQuotaThresholds,
type QuotaInfo,
} from "../quotaPreflight.ts";
import { getProviderConnectionById } from "../../../src/lib/db/providers";
import {
resolveResilienceSettings,
type ResilienceSettings,
} from "../../../src/lib/resilience/settings";
import { fetchResetAwareQuotaWithCache } from "./quotaStrategies.ts";
import type { ResetWindowConfig } from "./quotaScoring.ts";
function asThresholdMap(value: unknown): Record<string, number> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const result: Record<string, number> = {};
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
const numeric = Number(raw);
if (key && Number.isFinite(numeric)) result[key] = numeric;
}
return result;
}
function quotaWindowLookupNames(provider: string, windowName: string): string[] {
const names = [windowName];
const lower = windowName.toLowerCase();
if (lower !== windowName) names.push(lower);
if (provider === "codex") {
if (lower.includes("session") || lower === "5h" || lower === "five_hour") names.push("session");
if (lower.includes("weekly") || lower === "7d" || lower === "seven_day") names.push("weekly");
if (lower.includes("monthly") || lower === "30d") names.push("monthly");
}
return [...new Set(names)];
}
export function buildAutoQuotaThresholds(
provider: string,
connection: Record<string, unknown> | undefined,
resilienceSettings: ResilienceSettings | null | undefined
): PreflightQuotaThresholds {
const quotaPreflight = (resilienceSettings ?? resolveResilienceSettings(null))?.quotaPreflight;
const defaultThresholdPercent = quotaPreflight?.defaultThresholdPercent ?? 2;
const warnThresholdPercent = quotaPreflight?.warnThresholdPercent ?? 20;
const providerWindowMap = asThresholdMap(quotaPreflight?.providerWindowDefaults?.[provider]);
const perConnectionWindowOverrides = asThresholdMap(connection?.quotaWindowThresholds);
return {
resolveMinRemainingPercent: (windowName: string | null): number => {
if (windowName !== null) {
for (const lookupWindowName of quotaWindowLookupNames(provider, windowName)) {
const override = perConnectionWindowOverrides[lookupWindowName];
if (typeof override === "number") return override;
const providerDefault = providerWindowMap[lookupWindowName];
if (typeof providerDefault === "number") return providerDefault;
}
}
return defaultThresholdPercent;
},
resolveWarnRemainingPercent: () => warnThresholdPercent,
};
}
/**
* #5923 (Finding #4) — Shared quota-exhaustion cutoff predicate, scoped strictly
* per (provider, connectionId, model window). Extracted from the inline logic
* that `buildAutoCandidates` has always used (fetch via the SAME
* `fetchResetAwareQuotaWithCache` cache, evaluate via the SAME pure
* `evaluateQuotaCutoff` + `buildAutoQuotaThresholds`), so priority/weighted/etc.
* strategies honor the operator's configured quota cutoff instead of only the
* "auto" strategy.
*
* Gated behind the SAME opt-in setting as the auto-strategy cutoff
* (`resilienceSettings.quotaPreflight.enabled`) — when that setting is off this
* is a no-op, exactly like the auto path. Never touches the provider circuit
* breaker; a blocked result only means "skip this one connection", leaving
* every sibling connection/model for the same provider fully eligible.
*/
export async function resolveQuotaExhaustionCutoffForTarget(
provider: string,
connectionId: string | undefined,
resilienceSettings: ResilienceSettings | null | undefined,
resetWindowConfig: ResetWindowConfig,
comboName: string,
log: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void }
): Promise<{ blocked: boolean; reason?: string }> {
const quotaCutoffEnabled =
(resilienceSettings ?? resolveResilienceSettings(null))?.quotaPreflight?.enabled === true;
if (!quotaCutoffEnabled || !provider || !connectionId) return { blocked: false };
const fetcher = getQuotaFetcher(provider);
if (!fetcher) return { blocked: false };
let connection: Record<string, unknown> | undefined;
try {
connection = (await getProviderConnectionById(connectionId)) as
| Record<string, unknown>
| undefined;
} catch {
connection = undefined;
}
try {
const quota = await fetchResetAwareQuotaWithCache({
provider,
connectionId,
connection,
fetcher,
config: resetWindowConfig,
log,
comboName,
});
const cutoffDecision = evaluateQuotaCutoff(
quota as QuotaInfo | null,
buildAutoQuotaThresholds(provider, connection, resilienceSettings)
);
if (!cutoffDecision.proceed) {
return { blocked: true, reason: cutoffDecision.reason || "quota_exhausted" };
}
} catch {
// Fail-open: never block routing because the preflight fetch itself errored.
return { blocked: false };
}
return { blocked: false };
}

View File

@@ -229,14 +229,17 @@ function convertContent(content) {
continue;
}
// Text with thoughtSignature = regular text after thinking
// Text with thoughtSignature = regular text after thinking.
// Skip empty text — Anthropic rejects empty content blocks with a 400.
if (part.thoughtSignature && part.text !== undefined) {
textParts.push({ type: "text", text: part.text });
if (part.text) {
textParts.push({ type: "text", text: part.text });
}
continue;
}
// Regular text
if (part.text !== undefined) {
// Regular text — skip empty strings (Anthropic rejects empty content blocks).
if (part.text !== undefined && part.text !== "") {
textParts.push({ type: "text", text: part.text });
}
@@ -274,8 +277,26 @@ function convertContent(content) {
}
}
// Content with only functionResponses → return array of tool messages
// Function responses may be co-located with function calls / text / reasoning in
// the same content. Emit the tool messages AND the accompanying assistant message so
// nothing is dropped (previously only the tool messages survived).
if (toolResults.length > 0) {
if (toolCalls.length > 0 || textParts.length > 0 || reasoningContent) {
const assistantMsg: JsonRecord = { role: "assistant" };
if (textParts.length > 0) {
assistantMsg.content =
textParts.length === 1 && textParts[0].type === "text"
? textParts[0].text
: textParts;
}
if (reasoningContent) {
assistantMsg.reasoning_content = reasoningContent;
}
if (toolCalls.length > 0) {
assistantMsg.tool_calls = toolCalls;
}
return [...toolResults, assistantMsg];
}
return toolResults;
}

View File

@@ -6,73 +6,28 @@
*/
import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults";
import { FORMATS } from "../formats.ts";
import { generateToolCallId } from "../helpers/toolCallHelper.ts";
import { register } from "../registry.ts";
import { normalizeResponsesInputForChat } from "../../utils/responsesInputNormalization.ts";
type JsonRecord = Record<string, unknown>;
const RESPONSES_STORE_MARKER = "_omnirouteResponsesStore";
const COPILOT_REASONING_SUMMARY_MARKER = "_omnirouteCopilotReasoningSummary";
import { openaiToOpenAIResponsesRequest } from "./openai-responses/toResponses.ts";
import {
JsonRecord,
RESPONSES_STORE_MARKER,
COPILOT_REASONING_SUMMARY_MARKER,
WEB_SEARCH_TOOL_TYPES,
TOOL_SEARCH_TOOL_TYPES,
IMAGE_GENERATION_TOOL_TYPES,
toRecord,
toArray,
toString,
normalizeVerbosity,
normalizeResponsesReasoningEffort,
shouldRequestClaudeSummarizedThinking,
unsupportedFeature,
} from "./openai-responses/helpers.ts";
// Forward-compatible regex: matches web_search, web_search_20250305, and future versioned names.
const WEB_SEARCH_TOOL_TYPES = /^web_search/;
// tool_search is a Responses API built-in sent by newer Codex clients; it has no Chat Completions
// equivalent and must be silently dropped (not rejected with 400).
const TOOL_SEARCH_TOOL_TYPES = /^tool_search/;
// image_generation is a Responses API hosted tool that Codex Desktop injects into every request
// (even text-only ones); it has no Chat Completions equivalent and must be silently dropped (#2950).
const IMAGE_GENERATION_TOOL_TYPES = /^image_generation/;
// GPT-5 output verbosity: `verbosity` on Chat Completions, `text.verbosity` on the
// Responses API. Only these three levels are valid upstream; anything else is dropped.
const VERBOSITY_LEVELS = new Set(["low", "medium", "high"]);
function normalizeVerbosity(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const level = value.toLowerCase();
return VERBOSITY_LEVELS.has(level) ? level : undefined;
}
function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
// The Responses API rejects call_id values longer than 64 characters (9router#396).
// Clamp deterministically so a function_call and its matching function_call_output keep
// the same id and stay paired through the orphaned-output filter below.
const MAX_CALL_ID_LEN = 64;
function clampCallId(id: string): string {
return id.length > MAX_CALL_ID_LEN ? id.slice(0, MAX_CALL_ID_LEN) : id;
}
function toArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function toString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function imageUrlToText(value: unknown): string {
if (typeof value === "string") return value;
const record = toRecord(value);
return toString(record.url);
}
function normalizeResponsesReasoningEffort(value: unknown): string {
const effort = toString(value).toLowerCase();
return effort === "max" ? "xhigh" : effort;
}
function shouldRequestClaudeSummarizedThinking(value: unknown): boolean {
const summary = toString(value).toLowerCase();
return !!summary && summary !== "off" && summary !== "none" && summary !== "disabled";
}
function unsupportedFeature(message: string): Error & { statusCode: number; errorType: string } {
const error = new Error(message) as Error & { statusCode: number; errorType: string };
error.statusCode = 400;
error.errorType = "unsupported_feature";
return error;
}
// chat -> Responses direction extracted to a pure leaf; re-exported for external
// importers (tests). Host imports it back for registration below.
export { openaiToOpenAIResponsesRequest } from "./openai-responses/toResponses.ts";
/**
* Convert OpenAI Responses API request to OpenAI Chat Completions format
@@ -569,326 +524,6 @@ export function openaiResponsesToOpenAIRequest(
return result;
}
/**
* Convert OpenAI Chat Completions to OpenAI Responses API format
*/
export function openaiToOpenAIResponsesRequest(
model: unknown,
body: unknown,
stream: unknown,
credentials: unknown
): unknown {
void stream;
const root = toRecord(body);
const credentialRecord = toRecord(credentials);
const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData);
const result: JsonRecord = {
model,
input: [],
stream: true,
};
if (!storeEnabled) {
result.store = false;
}
const input = result.input as JsonRecord[];
// Extract first system message as instructions
let hasSystemMessage = false;
const messages = toArray(root.messages);
for (const messageValue of messages) {
const msg = toRecord(messageValue);
const role = toString(msg.role);
if (role === "system" || role === "developer") {
if (!hasSystemMessage) {
result.instructions = typeof msg.content === "string" ? msg.content : "";
hasSystemMessage = true;
}
continue;
}
// Convert user messages
if (role === "user") {
const content =
typeof msg.content === "string"
? [{ type: "input_text", text: msg.content }]
: Array.isArray(msg.content)
? msg.content.map((contentValue) => {
const contentItem = toRecord(contentValue);
if (contentItem.type === "text") {
return { type: "input_text", text: toString(contentItem.text) };
}
if (contentItem.type === "image_url") {
const imgUrl = contentItem.image_url as
| string
| { url?: string; detail?: string };
const imgResult: JsonRecord = {
type: "input_image",
image_url: typeof imgUrl === "string" ? imgUrl : imgUrl?.url || "",
};
if (typeof imgUrl === "object" && imgUrl?.detail !== undefined) {
imgResult.detail = imgUrl.detail;
}
return imgResult;
}
if (
contentItem.type === "image" &&
typeof contentItem.image === "string" &&
/^data:([^;]+);base64,(.+)$/.test(contentItem.image)
) {
// AI SDK-style image part: { type: "image", image: "data:...;base64,..." } (#1330)
const imgResult: JsonRecord = {
type: "input_image",
image_url: contentItem.image,
detail: contentItem.detail !== undefined ? contentItem.detail : "auto",
};
return imgResult;
}
if (contentItem.type === "file" || contentItem.type === "document") {
// Accept both the OpenAI `file` shape and the Gemini-style `document` shape,
// and map the bare `data`/`url` fields too, so a PDF reaches Codex/Responses
// regardless of which content-part name the client used (#2515).
const file = toRecord(
contentItem.type === "document" ? contentItem.document : contentItem.file
);
const fileResult: JsonRecord = { type: "input_file" };
if (file.file_data !== undefined) fileResult.file_data = file.file_data;
else if (file.data !== undefined) fileResult.file_data = file.data;
if (file.file_id !== undefined) fileResult.file_id = file.file_id;
if (file.file_url !== undefined) fileResult.file_url = file.file_url;
else if (file.url !== undefined) fileResult.file_url = file.url;
if (file.filename !== undefined) fileResult.filename = file.filename;
else if (file.name !== undefined) fileResult.filename = file.name;
return fileResult;
}
return contentValue;
})
: [{ type: "input_text", text: "" }];
input.push({
type: "message",
role: "user",
content,
});
}
// Convert assistant messages
if (role === "assistant") {
// Skip reasoning_content — OpenAI Responses API requires server-generated
// rs_* IDs for reasoning items. Synthesizing client-side IDs (e.g. reasoning_N)
// causes 400 errors from Responses-compatible upstreams. (#224)
// Skip thinking blocks in array content — same rs_* ID constraint applies
// Build assistant output content
const outputContent: unknown[] = [];
if (typeof msg.content === "string" && msg.content) {
outputContent.push({ type: "output_text", text: msg.content });
} else if (Array.isArray(msg.content)) {
for (const contentValue of msg.content) {
const contentItem = toRecord(contentValue);
if (contentItem.type === "text") {
outputContent.push({ type: "output_text", text: toString(contentItem.text) });
} else if (contentItem.type === "image_url") {
const url = imageUrlToText(contentItem.image_url);
outputContent.push({ type: "output_text", text: url ? `[Image: ${url}]` : "[Image]" });
} else if (contentItem.type === "thinking" || contentItem.type === "redacted_thinking") {
// Reasoning already moved above
continue;
} else {
outputContent.push(contentValue);
}
}
}
// Only add assistant message if content exists
if (outputContent.length > 0) {
input.push({
type: "message",
role: "assistant",
content: outputContent,
});
}
// Convert tool_calls to function_call items
if (Array.isArray(msg.tool_calls)) {
for (const toolCallValue of msg.tool_calls) {
const toolCall = toRecord(toolCallValue);
const fn = toRecord(toolCall.function);
// Skip tool calls with empty names to avoid infinite placeholder_tool loops
const fnName = toString(fn.name).trim();
if (!fnName) {
continue;
}
input.push({
type: "function_call",
call_id: clampCallId(toString(toolCall.id).trim() || generateToolCallId()),
name: fnName,
arguments: toString(fn.arguments, "{}"),
});
}
}
// Handle deprecated function_call field (pre-tool_calls API)
if (msg.function_call && !msg.tool_calls) {
const fc = toRecord(msg.function_call);
const fnName = toString(fc.name).trim();
if (fnName) {
input.push({
type: "function_call",
call_id: clampCallId(`call_${fnName}`),
name: fnName,
arguments: toString(fc.arguments, "{}"),
});
}
}
}
// Convert tool results
if (role === "tool") {
input.push({
type: "function_call_output",
call_id: clampCallId(toString(msg.tool_call_id)),
output:
typeof msg.content === "string"
? msg.content
: Array.isArray(msg.content)
? msg.content.map((c) => {
const part = toRecord(c);
if (part.type === "text")
return { type: "input_text", text: toString(part.text) };
return c;
})
: String(msg.content ?? ""),
});
}
// Handle deprecated function role messages
if (role === "function") {
input.push({
type: "function_call_output",
call_id: clampCallId(`call_${toString(msg.name)}`),
output: typeof msg.content === "string" ? msg.content : String(msg.content ?? ""),
});
}
}
// Filter orphaned function_call_output items (no matching function_call)
// This happens when Claude Code compaction removes messages but leaves tool results
const knownCallIds = new Set(
input
.filter(
(item: { type?: string; call_id?: string }) => item.type === "function_call" && item.call_id
)
.map((item: { type?: string; call_id?: string }) => item.call_id)
);
result.input = input.filter((item: { type?: string; call_id?: string }) => {
if (item.type === "function_call_output" && item.call_id) {
return knownCallIds.has(item.call_id);
}
return true;
});
// If no system message, keep empty instructions
if (!hasSystemMessage) {
result.instructions = "";
}
// Convert tools format
if (Array.isArray(root.tools)) {
result.tools = root.tools.map((toolValue) => {
const tool = toRecord(toolValue);
if (tool.type === "function") {
const fn = toRecord(tool.function);
const name = toString(fn.name);
return {
type: "function",
name,
description: toString(fn.description),
parameters: fn.parameters,
strict: fn.strict,
};
}
return toolValue;
});
}
// Translate tool_choice: Chat {type,function:{name}} → Responses {type,name}
if (root.tool_choice !== undefined) {
if (typeof root.tool_choice === "string") {
result.tool_choice = root.tool_choice;
} else if (typeof root.tool_choice === "object" && !Array.isArray(root.tool_choice)) {
const tc = toRecord(root.tool_choice);
if (tc.type === "function" && tc.function) {
const fn = toRecord(tc.function);
result.tool_choice = { type: "function", name: fn.name };
} else {
result.tool_choice = root.tool_choice;
}
} else {
result.tool_choice = root.tool_choice;
}
}
// Pass through relevant fields
if (root.previous_response_id !== undefined) {
result.previous_response_id = root.previous_response_id;
}
if (root.prompt_cache_key !== undefined) {
result.prompt_cache_key = root.prompt_cache_key;
}
if (root.session_id !== undefined) {
result.session_id = root.session_id;
}
if (root.conversation_id !== undefined) {
result.conversation_id = root.conversation_id;
}
if (root.service_tier !== undefined) result.service_tier = root.service_tier;
if (root.temperature !== undefined) result.temperature = root.temperature;
// Translate max_tokens / max_completion_tokens → max_output_tokens for Responses API.
// The Responses API does not accept max_tokens or max_completion_tokens; it requires
// max_output_tokens. max_completion_tokens takes priority as the newer Chat Completions field.
if (root.max_completion_tokens !== undefined) {
result.max_output_tokens = root.max_completion_tokens;
} else if (root.max_tokens !== undefined) {
result.max_output_tokens = root.max_tokens;
}
if (root.top_p !== undefined) result.top_p = root.top_p;
// GPT-5 verbosity: Chat Completions `verbosity` → Responses `text.verbosity`.
const chatVerbosity = normalizeVerbosity(root.verbosity);
if (chatVerbosity) {
result.text = { ...toRecord(result.text), verbosity: chatVerbosity };
}
if (root.reasoning !== undefined) {
result.reasoning = root.reasoning;
} else if (root.reasoning_effort !== undefined) {
const effort = normalizeResponsesReasoningEffort(root.reasoning_effort);
if (effort) {
result.reasoning = { effort };
}
}
// Propagate Responses-API-only fields when a chat client sent them.
// Without this, e.g. `include: ["reasoning.encrypted_content"]` is lost on
// the way upstream and Codex returns an empty reasoning summary, so clients
// (OpenCode, Cursor, etc.) see no thinking stream.
if (Array.isArray(root.include) && root.include.length > 0) {
result.include = root.include;
}
if (storeEnabled) {
if (root[RESPONSES_STORE_MARKER] !== undefined) {
result.store = root[RESPONSES_STORE_MARKER];
} else if (root.store !== undefined) {
result.store = root.store;
}
}
return result;
}
// Register both directions
register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, openaiResponsesToOpenAIRequest, null);
register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, openaiToOpenAIResponsesRequest, null);

View File

@@ -0,0 +1,69 @@
// Pure shared primitives for the OpenAI Responses <-> Chat Completions request
// translators. Extracted verbatim from openai-responses.ts (no host imports).
export type JsonRecord = Record<string, unknown>;
export const RESPONSES_STORE_MARKER = "_omnirouteResponsesStore";
export const COPILOT_REASONING_SUMMARY_MARKER = "_omnirouteCopilotReasoningSummary";
// Forward-compatible regex: matches web_search, web_search_20250305, and future versioned names.
export const WEB_SEARCH_TOOL_TYPES = /^web_search/;
// tool_search is a Responses API built-in sent by newer Codex clients; it has no Chat Completions
// equivalent and must be silently dropped (not rejected with 400).
export const TOOL_SEARCH_TOOL_TYPES = /^tool_search/;
// image_generation is a Responses API hosted tool that Codex Desktop injects into every request
// (even text-only ones); it has no Chat Completions equivalent and must be silently dropped (#2950).
export const IMAGE_GENERATION_TOOL_TYPES = /^image_generation/;
// GPT-5 output verbosity: `verbosity` on Chat Completions, `text.verbosity` on the
// Responses API. Only these three levels are valid upstream; anything else is dropped.
export const VERBOSITY_LEVELS = new Set(["low", "medium", "high"]);
export function normalizeVerbosity(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const level = value.toLowerCase();
return VERBOSITY_LEVELS.has(level) ? level : undefined;
}
export function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
// The Responses API rejects call_id values longer than 64 characters (9router#396).
// Clamp deterministically so a function_call and its matching function_call_output keep
// the same id and stay paired through the orphaned-output filter below.
export const MAX_CALL_ID_LEN = 64;
export function clampCallId(id: string): string {
return id.length > MAX_CALL_ID_LEN ? id.slice(0, MAX_CALL_ID_LEN) : id;
}
export function toArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
export function toString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
export function imageUrlToText(value: unknown): string {
if (typeof value === "string") return value;
const record = toRecord(value);
return toString(record.url);
}
export function normalizeResponsesReasoningEffort(value: unknown): string {
const effort = toString(value).toLowerCase();
return effort === "max" ? "xhigh" : effort;
}
export function shouldRequestClaudeSummarizedThinking(value: unknown): boolean {
const summary = toString(value).toLowerCase();
return !!summary && summary !== "off" && summary !== "none" && summary !== "disabled";
}
export function unsupportedFeature(
message: string
): Error & { statusCode: number; errorType: string } {
const error = new Error(message) as Error & { statusCode: number; errorType: string };
error.statusCode = 400;
error.errorType = "unsupported_feature";
return error;
}

View File

@@ -0,0 +1,334 @@
/**
* Translator: OpenAI Chat Completions -> OpenAI Responses API
*
* Extracted verbatim from openai-responses.ts. Registration stays in the host.
*/
import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults";
import { generateToolCallId } from "../../helpers/toolCallHelper.ts";
import {
JsonRecord,
RESPONSES_STORE_MARKER,
toRecord,
toArray,
toString,
clampCallId,
imageUrlToText,
normalizeVerbosity,
normalizeResponsesReasoningEffort,
} from "./helpers.ts";
export function openaiToOpenAIResponsesRequest(
model: unknown,
body: unknown,
stream: unknown,
credentials: unknown
): unknown {
void stream;
const root = toRecord(body);
const credentialRecord = toRecord(credentials);
const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData);
const result: JsonRecord = {
model,
input: [],
stream: true,
};
if (!storeEnabled) {
result.store = false;
}
const input = result.input as JsonRecord[];
// Extract first system message as instructions
let hasSystemMessage = false;
const messages = toArray(root.messages);
for (const messageValue of messages) {
const msg = toRecord(messageValue);
const role = toString(msg.role);
if (role === "system" || role === "developer") {
if (!hasSystemMessage) {
result.instructions = typeof msg.content === "string" ? msg.content : "";
hasSystemMessage = true;
}
continue;
}
// Convert user messages
if (role === "user") {
const content =
typeof msg.content === "string"
? [{ type: "input_text", text: msg.content }]
: Array.isArray(msg.content)
? msg.content.map((contentValue) => {
const contentItem = toRecord(contentValue);
if (contentItem.type === "text") {
return { type: "input_text", text: toString(contentItem.text) };
}
if (contentItem.type === "image_url") {
const imgUrl = contentItem.image_url as
string | { url?: string; detail?: string };
const imgResult: JsonRecord = {
type: "input_image",
image_url: typeof imgUrl === "string" ? imgUrl : imgUrl?.url || "",
};
if (typeof imgUrl === "object" && imgUrl?.detail !== undefined) {
imgResult.detail = imgUrl.detail;
}
return imgResult;
}
if (
contentItem.type === "image" &&
typeof contentItem.image === "string" &&
/^data:([^;]+);base64,(.+)$/.test(contentItem.image)
) {
// AI SDK-style image part: { type: "image", image: "data:...;base64,..." } (#1330)
const imgResult: JsonRecord = {
type: "input_image",
image_url: contentItem.image,
detail: contentItem.detail !== undefined ? contentItem.detail : "auto",
};
return imgResult;
}
if (contentItem.type === "file" || contentItem.type === "document") {
// Accept both the OpenAI `file` shape and the Gemini-style `document` shape,
// and map the bare `data`/`url` fields too, so a PDF reaches Codex/Responses
// regardless of which content-part name the client used (#2515).
const file = toRecord(
contentItem.type === "document" ? contentItem.document : contentItem.file
);
const fileResult: JsonRecord = { type: "input_file" };
if (file.file_data !== undefined) fileResult.file_data = file.file_data;
else if (file.data !== undefined) fileResult.file_data = file.data;
if (file.file_id !== undefined) fileResult.file_id = file.file_id;
if (file.file_url !== undefined) fileResult.file_url = file.file_url;
else if (file.url !== undefined) fileResult.file_url = file.url;
if (file.filename !== undefined) fileResult.filename = file.filename;
else if (file.name !== undefined) fileResult.filename = file.name;
return fileResult;
}
return contentValue;
})
: [{ type: "input_text", text: "" }];
input.push({
type: "message",
role: "user",
content,
});
}
// Convert assistant messages
if (role === "assistant") {
// Skip reasoning_content — OpenAI Responses API requires server-generated
// rs_* IDs for reasoning items. Synthesizing client-side IDs (e.g. reasoning_N)
// causes 400 errors from Responses-compatible upstreams. (#224)
// Skip thinking blocks in array content — same rs_* ID constraint applies
// Build assistant output content
const outputContent: unknown[] = [];
if (typeof msg.content === "string" && msg.content) {
outputContent.push({ type: "output_text", text: msg.content });
} else if (Array.isArray(msg.content)) {
for (const contentValue of msg.content) {
const contentItem = toRecord(contentValue);
if (contentItem.type === "text") {
outputContent.push({ type: "output_text", text: toString(contentItem.text) });
} else if (contentItem.type === "image_url") {
const url = imageUrlToText(contentItem.image_url);
outputContent.push({ type: "output_text", text: url ? `[Image: ${url}]` : "[Image]" });
} else if (contentItem.type === "thinking" || contentItem.type === "redacted_thinking") {
// Reasoning already moved above
continue;
} else {
outputContent.push(contentValue);
}
}
}
// Only add assistant message if content exists
if (outputContent.length > 0) {
input.push({
type: "message",
role: "assistant",
content: outputContent,
});
}
// Convert tool_calls to function_call items
if (Array.isArray(msg.tool_calls)) {
for (const toolCallValue of msg.tool_calls) {
const toolCall = toRecord(toolCallValue);
const fn = toRecord(toolCall.function);
// Skip tool calls with empty names to avoid infinite placeholder_tool loops
const fnName = toString(fn.name).trim();
if (!fnName) {
continue;
}
input.push({
type: "function_call",
call_id: clampCallId(toString(toolCall.id).trim() || generateToolCallId()),
name: fnName,
arguments: toString(fn.arguments, "{}"),
});
}
}
// Handle deprecated function_call field (pre-tool_calls API)
if (msg.function_call && !msg.tool_calls) {
const fc = toRecord(msg.function_call);
const fnName = toString(fc.name).trim();
if (fnName) {
input.push({
type: "function_call",
call_id: clampCallId(`call_${fnName}`),
name: fnName,
arguments: toString(fc.arguments, "{}"),
});
}
}
}
// Convert tool results
if (role === "tool") {
input.push({
type: "function_call_output",
call_id: clampCallId(toString(msg.tool_call_id)),
output:
typeof msg.content === "string"
? msg.content
: Array.isArray(msg.content)
? msg.content.map((c) => {
const part = toRecord(c);
if (part.type === "text")
return { type: "input_text", text: toString(part.text) };
return c;
})
: String(msg.content ?? ""),
});
}
// Handle deprecated function role messages
if (role === "function") {
input.push({
type: "function_call_output",
call_id: clampCallId(`call_${toString(msg.name)}`),
output: typeof msg.content === "string" ? msg.content : String(msg.content ?? ""),
});
}
}
// Filter orphaned function_call_output items (no matching function_call)
// This happens when Claude Code compaction removes messages but leaves tool results
const knownCallIds = new Set(
input
.filter(
(item: { type?: string; call_id?: string }) => item.type === "function_call" && item.call_id
)
.map((item: { type?: string; call_id?: string }) => item.call_id)
);
result.input = input.filter((item: { type?: string; call_id?: string }) => {
if (item.type === "function_call_output" && item.call_id) {
return knownCallIds.has(item.call_id);
}
return true;
});
// If no system message, keep empty instructions
if (!hasSystemMessage) {
result.instructions = "";
}
// Convert tools format
if (Array.isArray(root.tools)) {
result.tools = root.tools.map((toolValue) => {
const tool = toRecord(toolValue);
if (tool.type === "function") {
const fn = toRecord(tool.function);
const name = toString(fn.name);
return {
type: "function",
name,
description: toString(fn.description),
parameters: fn.parameters,
strict: fn.strict,
};
}
return toolValue;
});
}
// Translate tool_choice: Chat {type,function:{name}} → Responses {type,name}
if (root.tool_choice !== undefined) {
if (typeof root.tool_choice === "string") {
result.tool_choice = root.tool_choice;
} else if (typeof root.tool_choice === "object" && !Array.isArray(root.tool_choice)) {
const tc = toRecord(root.tool_choice);
if (tc.type === "function" && tc.function) {
const fn = toRecord(tc.function);
result.tool_choice = { type: "function", name: fn.name };
} else {
result.tool_choice = root.tool_choice;
}
} else {
result.tool_choice = root.tool_choice;
}
}
// Pass through relevant fields
if (root.previous_response_id !== undefined) {
result.previous_response_id = root.previous_response_id;
}
if (root.prompt_cache_key !== undefined) {
result.prompt_cache_key = root.prompt_cache_key;
}
if (root.session_id !== undefined) {
result.session_id = root.session_id;
}
if (root.conversation_id !== undefined) {
result.conversation_id = root.conversation_id;
}
if (root.service_tier !== undefined) result.service_tier = root.service_tier;
if (root.temperature !== undefined) result.temperature = root.temperature;
// Translate max_tokens / max_completion_tokens → max_output_tokens for Responses API.
// The Responses API does not accept max_tokens or max_completion_tokens; it requires
// max_output_tokens. max_completion_tokens takes priority as the newer Chat Completions field.
if (root.max_completion_tokens !== undefined) {
result.max_output_tokens = root.max_completion_tokens;
} else if (root.max_tokens !== undefined) {
result.max_output_tokens = root.max_tokens;
}
if (root.top_p !== undefined) result.top_p = root.top_p;
// GPT-5 verbosity: Chat Completions `verbosity` → Responses `text.verbosity`.
const chatVerbosity = normalizeVerbosity(root.verbosity);
if (chatVerbosity) {
result.text = { ...toRecord(result.text), verbosity: chatVerbosity };
}
if (root.reasoning !== undefined) {
result.reasoning = root.reasoning;
} else if (root.reasoning_effort !== undefined) {
const effort = normalizeResponsesReasoningEffort(root.reasoning_effort);
if (effort) {
result.reasoning = { effort };
}
}
// Propagate Responses-API-only fields when a chat client sent them.
// Without this, e.g. `include: ["reasoning.encrypted_content"]` is lost on
// the way upstream and Codex returns an empty reasoning summary, so clients
// (OpenCode, Cursor, etc.) see no thinking stream.
if (Array.isArray(root.include) && root.include.length > 0) {
result.include = root.include;
}
if (storeEnabled) {
if (root[RESPONSES_STORE_MARKER] !== undefined) {
result.store = root[RESPONSES_STORE_MARKER];
} else if (root.store !== undefined) {
result.store = root.store;
}
}
return result;
}

View File

@@ -6,8 +6,8 @@ import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts";
import { sanitizeToolId } from "../helpers/schemaCoercion.ts";
import { safeParseJSON } from "../helpers/jsonUtil.ts";
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
import { capMaxOutputTokens } from "../../../src/lib/modelCapabilities.ts";
import { isAdaptiveThinkingOnly } from "../../../src/shared/constants/modelSpecs.ts";
import { fitThinkingToMaxTokens } from "./openai-to-claude/thinkingBudget.ts";
// Reasoning-effort levels Anthropic accepts on `output_config.effort`. Used to steer
// adaptive-only Claude models (Opus 4.7+/Fable 5) without ever emitting a manual budget.
@@ -36,93 +36,9 @@ function applyCopilotSummarizedThinkingDisplay(
};
}
// Anthropic constraints for the thinking + max_tokens contract:
// - thinking.budget_tokens must be >= 1024 when thinking is enabled
// - max_tokens must be > thinking.budget_tokens (covers thinking + response)
// - max_tokens must be <= model output cap (e.g. 128000 for Opus 4.7)
const MIN_CLAUDE_THINKING_BUDGET = 1024;
const MIN_RESPONSE_ROOM = 1024;
function safeCapMaxOutputTokens(model: string): number | null {
try {
const cap = capMaxOutputTokens(model);
return typeof cap === "number" && cap > 0 ? cap : null;
} catch {
return null;
}
}
/**
* Fit Claude thinking budget within the model's max output cap.
*
* Replaces the previous unconditional `max_tokens = budget + 8192` inflation,
* which could exceed the model output cap (e.g. Opus 4.7's 128000 ceiling) and
* trigger HTTP 400 from Anthropic ("max_tokens > 128000").
*
* Strategy (preserves caller intent up to the model cap):
* - Preserve caller's max_tokens as response room (floored to MIN_RESPONSE_ROOM)
* - Target max_tokens = responseRoom + requestedBudget, capped at modelCap
* - fittedBudget = max_tokens - responseRoom (the thinking budget actually used)
* - If the cap squeezes fittedBudget below the Anthropic minimum, retry with
* responseRoom shrunk to MIN_RESPONSE_ROOM; if still below MIN, disable
* thinking entirely (cap too tight for any reasoning).
*
* Worked example (real-world Opus 4.7 case that previously 400'd):
* caller max_tokens = 32000, reasoning_effort=high → budget = 131072,
* model cap = 128000.
* responseRoom = max(32000, 1024) = 32000
* target = min(32000 + 131072, 128000) = 128000
* fittedBudget = 128000 - 32000 = 96000 (>= 1024, OK)
* → max_tokens=128000, budget_tokens=96000 (vs. the old buggy 139264 / 131072).
*/
export function fitThinkingToMaxTokens(
model: string,
callerMaxTokens: number,
thinking: Record<string, unknown> | undefined
): { maxTokens: number; thinking: Record<string, unknown> | undefined } {
const modelCap = safeCapMaxOutputTokens(model);
const requestedBudget = Number(thinking?.budget_tokens) || 0;
// No budgeted thinking — just cap max_tokens to the model output ceiling.
if (!thinking || requestedBudget <= 0) {
return {
maxTokens:
modelCap === null
? Math.max(callerMaxTokens, 1)
: Math.min(Math.max(callerMaxTokens, 1), modelCap),
thinking,
};
}
let responseRoom = Math.max(callerMaxTokens, MIN_RESPONSE_ROOM);
let target =
modelCap === null
? responseRoom + requestedBudget
: Math.min(responseRoom + requestedBudget, modelCap);
let fittedBudget = target - responseRoom;
// If the cap squeezed thinking below Anthropic's floor, try shrinking
// response room to MIN_RESPONSE_ROOM to recover budget.
if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET && responseRoom > MIN_RESPONSE_ROOM) {
responseRoom = MIN_RESPONSE_ROOM;
target =
modelCap === null
? responseRoom + requestedBudget
: Math.min(responseRoom + requestedBudget, modelCap);
fittedBudget = target - responseRoom;
}
// Cap too tight for any thinking — disable rather than send an invalid request.
if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET) {
return { maxTokens: modelCap ?? Math.max(callerMaxTokens, 1), thinking: undefined };
}
const adjustedThinking: Record<string, unknown> = { ...thinking };
if (fittedBudget < requestedBudget) {
adjustedThinking.budget_tokens = fittedBudget;
}
return { maxTokens: target, thinking: adjustedThinking };
}
// Thinking-budget fitting extracted to a pure leaf; re-exported for external
// importers (tests). Host also uses fitThinkingToMaxTokens internally.
export { fitThinkingToMaxTokens } from "./openai-to-claude/thinkingBudget.ts";
type ClaudeContentBlock = Record<string, unknown>;
type ClaudeMessage = {
@@ -249,6 +165,97 @@ export function openaiToClaudeRequest(model, body, stream) {
result.stop_sequences = Array.isArray(body.stop) ? body.stop : [body.stop];
}
// Thinking configuration
// NOTE: computed BEFORE message-block conversion (below) so that
// `getContentBlocksFromMessage` knows whether the outbound request actually has
// extended thinking enabled — required to correctly gate the `redacted_thinking`
// replay-placeholder injection (#5945). This block has no dependency on
// `result.messages`/`toolNameMap`, so moving it earlier is safe.
if (body.thinking) {
result.thinking = {
type: body.thinking.type || "enabled",
...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }),
...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens }),
};
} else if (body.reasoning_effort) {
// Convert OpenAI reasoning_effort to Claude thinking format (#627)
// Clients like OpenCode send reasoning_effort via @ai-sdk/openai-compatible
const requestedEffort = String(body.reasoning_effort).toLowerCase();
const normalizedEffort =
requestedEffort === "max" && !supportsClaudeMaxEffort(model)
? "high"
: requestedEffort === "xhigh" && !supportsXHighEffort("claude", model)
? "high"
: requestedEffort;
if (isAdaptiveThinkingOnly(model)) {
// Opus 4.7+/Fable 5 removed manual extended thinking: a fixed `budget_tokens`
// (or `type:"enabled"`) is a hard 400. Steer EVERY level via adaptive +
// output_config.effort instead of the budget buckets below. Unrecognized levels
// leave thinking unset so the model keeps its adaptive default rather than 400ing
// on an invalid effort value.
if (ADAPTIVE_EFFORT_LEVELS.has(normalizedEffort)) {
result.thinking = {
type: "adaptive",
};
result.output_config = {
...(result.output_config || {}),
effort: normalizedEffort,
};
}
} else if (normalizedEffort === "max" || normalizedEffort === "xhigh") {
result.thinking = {
type: "adaptive",
};
result.output_config = {
...(result.output_config || {}),
effort: normalizedEffort,
};
} else {
const effortBudgetMap: Record<string, number> = {
low: 1024,
medium: 10240,
high: 131072,
max: 131072,
};
const budget = effortBudgetMap[normalizedEffort];
if (budget !== undefined && budget > 0) {
result.thinking = {
type: "enabled",
budget_tokens: budget,
};
}
}
}
// Fit thinking budget within the model's output cap and ensure
// max_tokens > budget_tokens for all thinking configurations (#627).
// Replaces the previous unconditional `budget + 8192` inflation, which
// could exceed model caps (e.g. Opus 4.7's 128000 ceiling) and trigger
// HTTP 400 from Anthropic.
const fitted = fitThinkingToMaxTokens(model, Number(result.max_tokens) || 0, result.thinking);
result.max_tokens = fitted.maxTokens;
if (fitted.thinking === undefined) {
delete result.thinking;
} else {
result.thinking = applyCopilotSummarizedThinkingDisplay(fitted.thinking, body);
}
delete result[COPILOT_REASONING_SUMMARY_MARKER];
// Final guard: Claude rejects `temperature` whenever extended thinking is
// enabled. If `result.thinking` was set above from `body.thinking` or
// `body.reasoning_effort` (manual budget or adaptive effort), drop temperature
// defensively. The model-name strip earlier already covers Claude OAuth's
// forced-thinking case (claude-opus-4.x / claude-sonnet-4.x).
if (result.thinking && result.temperature !== undefined) {
delete result.temperature;
}
// Whether the OUTBOUND request actually has extended thinking enabled. Anthropic's
// schema only requires a precursor thinking/redacted_thinking block before a tool_use
// block when thinking mode is active for THIS request — never unconditionally (#5945).
const thinkingEnabledForRequest = Boolean(result.thinking) && result.thinking.type !== "disabled";
// Messages
const systemParts = [];
@@ -283,7 +290,12 @@ export function openaiToClaudeRequest(model, body, stream) {
for (const msg of nonSystemMessages) {
const newRole = msg.role === "user" || msg.role === "tool" ? "user" : "assistant";
const blocks = getContentBlocksFromMessage(msg, toolNameMap, disableToolPrefix);
const blocks = getContentBlocksFromMessage(
msg,
toolNameMap,
disableToolPrefix,
thinkingEnabledForRequest
);
const hasToolUse = blocks.some((b) => b.type === "tool_use");
const hasToolResult = blocks.some((b) => b.type === "tool_result");
@@ -471,87 +483,6 @@ export function openaiToClaudeRequest(model, body, stream) {
: [{ type: "text", text: String(body.system) }];
}
// Thinking configuration
if (body.thinking) {
result.thinking = {
type: body.thinking.type || "enabled",
...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }),
...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens }),
};
} else if (body.reasoning_effort) {
// Convert OpenAI reasoning_effort to Claude thinking format (#627)
// Clients like OpenCode send reasoning_effort via @ai-sdk/openai-compatible
const requestedEffort = String(body.reasoning_effort).toLowerCase();
const normalizedEffort =
requestedEffort === "max" && !supportsClaudeMaxEffort(model)
? "high"
: requestedEffort === "xhigh" && !supportsXHighEffort("claude", model)
? "high"
: requestedEffort;
if (isAdaptiveThinkingOnly(model)) {
// Opus 4.7+/Fable 5 removed manual extended thinking: a fixed `budget_tokens`
// (or `type:"enabled"`) is a hard 400. Steer EVERY level via adaptive +
// output_config.effort instead of the budget buckets below. Unrecognized levels
// leave thinking unset so the model keeps its adaptive default rather than 400ing
// on an invalid effort value.
if (ADAPTIVE_EFFORT_LEVELS.has(normalizedEffort)) {
result.thinking = {
type: "adaptive",
};
result.output_config = {
...(result.output_config || {}),
effort: normalizedEffort,
};
}
} else if (normalizedEffort === "max" || normalizedEffort === "xhigh") {
result.thinking = {
type: "adaptive",
};
result.output_config = {
...(result.output_config || {}),
effort: normalizedEffort,
};
} else {
const effortBudgetMap: Record<string, number> = {
low: 1024,
medium: 10240,
high: 131072,
max: 131072,
};
const budget = effortBudgetMap[normalizedEffort];
if (budget !== undefined && budget > 0) {
result.thinking = {
type: "enabled",
budget_tokens: budget,
};
}
}
}
// Fit thinking budget within the model's output cap and ensure
// max_tokens > budget_tokens for all thinking configurations (#627).
// Replaces the previous unconditional `budget + 8192` inflation, which
// could exceed model caps (e.g. Opus 4.7's 128000 ceiling) and trigger
// HTTP 400 from Anthropic.
const fitted = fitThinkingToMaxTokens(model, Number(result.max_tokens) || 0, result.thinking);
result.max_tokens = fitted.maxTokens;
if (fitted.thinking === undefined) {
delete result.thinking;
} else {
result.thinking = applyCopilotSummarizedThinkingDisplay(fitted.thinking, body);
}
delete result[COPILOT_REASONING_SUMMARY_MARKER];
// Final guard: Claude rejects `temperature` whenever extended thinking is
// enabled. If `result.thinking` was set above from `body.thinking` or
// `body.reasoning_effort` (manual budget or adaptive effort), drop temperature
// defensively. The model-name strip earlier already covers Claude OAuth's
// forced-thinking case (claude-opus-4.x / claude-sonnet-4.x).
if (result.thinking && result.temperature !== undefined) {
delete result.temperature;
}
// Attach toolNameMap to result for response translation
if (toolNameMap.size > 0) {
result._toolNameMap = toolNameMap;
@@ -572,7 +503,12 @@ export function openaiToClaudeRequest(model, body, stream) {
}
// Get content blocks from single message
function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPrefix = false) {
function getContentBlocksFromMessage(
msg,
toolNameMap = new Map(),
disableToolPrefix = false,
thinkingEnabledForRequest = false
) {
const blocks = [];
if (msg.role === "tool") {
@@ -639,22 +575,6 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr
}
}
} else if (msg.role === "assistant") {
// Add reasoning_content as a replay placeholder (OpenAI extended thinking format).
// #5312 RC-D: reasoning_content carries NO real Claude signature. Emitting a
// `thinking` block with the fabricated DEFAULT signature makes Anthropic reject the
// replay with 400 "Invalid signature in thinking block" — and claudeHelper's
// latest-assistant guard (prepareClaudeRequest) preserves it verbatim, so the fake
// signature leaks upstream. Emit a signature-less redacted_thinking block instead
// (the same shape prepareClaudeRequest produces for Anthropic-native replay);
// Anthropic accepts it without signature validation and non-Anthropic Claude-shape
// upstreams re-hydrate the real text downstream from reasoningCache.
if (msg.reasoning_content) {
blocks.push({
type: "redacted_thinking",
data: DEFAULT_THINKING_CLAUDE_SIGNATURE,
});
}
if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "text" && part.text) {
@@ -703,6 +623,37 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr
}
}
}
// Add reasoning_content as a replay placeholder (OpenAI extended thinking format) —
// ONLY when Anthropic's schema actually requires a precursor thinking block: the
// outbound request has extended thinking enabled AND this assistant turn contains a
// tool_use block (Anthropic rejects a tool_use turn without a preceding
// thinking/redacted_thinking block when thinking is active). #5312 RC-D:
// reasoning_content carries NO real Claude signature. Emitting a `thinking` block
// with the fabricated DEFAULT signature makes Anthropic reject the replay with 400
// "Invalid signature in thinking block" — and claudeHelper's latest-assistant guard
// (prepareClaudeRequest) preserves it verbatim, so the fake signature leaks
// upstream. Emit a signature-less redacted_thinking block instead (the same shape
// prepareClaudeRequest produces for Anthropic-native replay, gated the same way at
// claudeHelper.ts `thinkingEnabled && !hasThinking && hasToolUse`); Anthropic
// accepts it without signature validation and non-Anthropic Claude-shape upstreams
// re-hydrate the real text downstream from reasoningCache.
// #5945: injecting this unconditionally — for ANY assistant turn carrying
// reasoning_content, regardless of tool_use or thinking state — fabricates a content
// block the client never sent. Some upstream clients (reported: Claude Sonnet 5 via
// the "Pi" harness) detect the extra block and refuse the turn as prompt injection.
// Drop reasoning_content silently when it is not required by the schema, mirroring
// how other echo-only fields are dropped (see OPENAI_INCOMPATIBLE_ECHO_FIELDS).
const hasThinkingBlock = blocks.some(
(b) => b.type === "thinking" || b.type === "redacted_thinking"
);
const hasToolUseBlock = blocks.some((b) => b.type === "tool_use");
if (msg.reasoning_content && thinkingEnabledForRequest && hasToolUseBlock && !hasThinkingBlock) {
blocks.unshift({
type: "redacted_thinking",
data: DEFAULT_THINKING_CLAUDE_SIGNATURE,
});
}
}
return blocks;

View File

@@ -0,0 +1,89 @@
import { capMaxOutputTokens } from "../../../../src/lib/modelCapabilities.ts";
// Anthropic constraints for the thinking + max_tokens contract:
// - thinking.budget_tokens must be >= 1024 when thinking is enabled
// - max_tokens must be > thinking.budget_tokens (covers thinking + response)
// - max_tokens must be <= model output cap (e.g. 128000 for Opus 4.7)
const MIN_CLAUDE_THINKING_BUDGET = 1024;
const MIN_RESPONSE_ROOM = 1024;
function safeCapMaxOutputTokens(model: string): number | null {
try {
const cap = capMaxOutputTokens(model);
return typeof cap === "number" && cap > 0 ? cap : null;
} catch {
return null;
}
}
/**
* Fit Claude thinking budget within the model's max output cap.
*
* Replaces the previous unconditional `max_tokens = budget + 8192` inflation,
* which could exceed the model output cap (e.g. Opus 4.7's 128000 ceiling) and
* trigger HTTP 400 from Anthropic ("max_tokens > 128000").
*
* Strategy (preserves caller intent up to the model cap):
* - Preserve caller's max_tokens as response room (floored to MIN_RESPONSE_ROOM)
* - Target max_tokens = responseRoom + requestedBudget, capped at modelCap
* - fittedBudget = max_tokens - responseRoom (the thinking budget actually used)
* - If the cap squeezes fittedBudget below the Anthropic minimum, retry with
* responseRoom shrunk to MIN_RESPONSE_ROOM; if still below MIN, disable
* thinking entirely (cap too tight for any reasoning).
*
* Worked example (real-world Opus 4.7 case that previously 400'd):
* caller max_tokens = 32000, reasoning_effort=high → budget = 131072,
* model cap = 128000.
* responseRoom = max(32000, 1024) = 32000
* target = min(32000 + 131072, 128000) = 128000
* fittedBudget = 128000 - 32000 = 96000 (>= 1024, OK)
* → max_tokens=128000, budget_tokens=96000 (vs. the old buggy 139264 / 131072).
*/
export function fitThinkingToMaxTokens(
model: string,
callerMaxTokens: number,
thinking: Record<string, unknown> | undefined
): { maxTokens: number; thinking: Record<string, unknown> | undefined } {
const modelCap = safeCapMaxOutputTokens(model);
const requestedBudget = Number(thinking?.budget_tokens) || 0;
// No budgeted thinking — just cap max_tokens to the model output ceiling.
if (!thinking || requestedBudget <= 0) {
return {
maxTokens:
modelCap === null
? Math.max(callerMaxTokens, 1)
: Math.min(Math.max(callerMaxTokens, 1), modelCap),
thinking,
};
}
let responseRoom = Math.max(callerMaxTokens, MIN_RESPONSE_ROOM);
let target =
modelCap === null
? responseRoom + requestedBudget
: Math.min(responseRoom + requestedBudget, modelCap);
let fittedBudget = target - responseRoom;
// If the cap squeezed thinking below Anthropic's floor, try shrinking
// response room to MIN_RESPONSE_ROOM to recover budget.
if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET && responseRoom > MIN_RESPONSE_ROOM) {
responseRoom = MIN_RESPONSE_ROOM;
target =
modelCap === null
? responseRoom + requestedBudget
: Math.min(responseRoom + requestedBudget, modelCap);
fittedBudget = target - responseRoom;
}
// Cap too tight for any thinking — disable rather than send an invalid request.
if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET) {
return { maxTokens: modelCap ?? Math.max(callerMaxTokens, 1), thinking: undefined };
}
const adjustedThinking: Record<string, unknown> = { ...thinking };
if (fittedBudget < requestedBudget) {
adjustedThinking.budget_tokens = fittedBudget;
}
return { maxTokens: target, thinking: adjustedThinking };
}

View File

@@ -9,6 +9,7 @@ import {
containsTextualToolCallMarker,
} from "../../utils/textualToolCall.ts";
import { normalizeOpenAICompatibleFinishReasonString } from "../../utils/finishReason.ts";
import { stripAnsiCodes } from "../../utils/streamHelpers.ts";
type GeminiToOpenAIState = {
functionIndex: number;
@@ -401,6 +402,10 @@ export function geminiToOpenAIResponse(chunk, state) {
// Process parts
if (content?.parts) {
for (const part of content.parts) {
// Normalize the part text once: strip ANSI/VT100 escape codes that some
// upstreams (gemini-cli terminal redraws) inject, so the `<thinking>` /
// `[Tool call:]` textual parsers below never see stray control bytes (#2273).
const partText = stripAnsiCodes(part.text);
const hasThoughtSig = part.thoughtSignature || part.thought_signature;
const isThought = part.thought === true;
if (hasThoughtSig && typeof hasThoughtSig === "string") {
@@ -409,7 +414,7 @@ export function geminiToOpenAIResponse(chunk, state) {
// Handle thought signature (thinking mode) or native gemini thought flag
if (hasThoughtSig || isThought) {
const hasTextContent = part.text !== undefined && part.text !== "";
const hasTextContent = partText !== undefined && partText !== "";
const hasFunctionCall = !!part.functionCall;
// Gemini/Antigravity can emit thoughtSignature as a standalone part
@@ -433,7 +438,7 @@ export function geminiToOpenAIResponse(chunk, state) {
choices: [
{
index: 0,
delta: isThought ? { reasoning_content: part.text } : { content: part.text },
delta: isThought ? { reasoning_content: partText } : { content: partText },
finish_reason: null,
},
],
@@ -463,10 +468,10 @@ export function geminiToOpenAIResponse(chunk, state) {
// "[Tool call: ...]" block instead of native functionCall. Convert that
// back to a structured OpenAI tool call so clients/tools do not see it as
// assistant prose.
if (part.text !== undefined && part.text !== "") {
if (partText !== undefined && partText !== "") {
const afterReasoning = parseTextualReasoningTags
? consumeTextualReasoningTags(part.text, state, results)
: part.text;
? consumeTextualReasoningTags(partText, state, results)
: partText;
if (!afterReasoning) continue;
let accumulated = (state.textualToolCallBuffer || "") + afterReasoning;

View File

@@ -7,38 +7,16 @@ import { FORMATS } from "../formats.ts";
import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts";
import { fallbackToolCallId } from "../helpers/toolCallHelper.ts";
import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts";
import {
normalizeToolName,
stripEmptyOptionalToolArgs,
normalizeOutputIndex,
normalizeUpstreamFailure,
extractResponsesReasoningSummaryText,
} from "./openai-responses/pureHelpers.ts";
function normalizeToolName(value) {
return typeof value === "string" ? value.trim() : "";
}
function stripEmptyOptionalToolArgs(value, toolName) {
if (value == null) return value;
if (typeof value === "string") {
// JSON-string cleanup is intentionally scoped to Claude Code's Read tool.
// For arbitrary tools, empty strings/arrays may be valid user payloads.
if (toolName !== "Read") return value;
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed) || typeof parsed !== "object" || parsed === null) return value;
const cleaned = stripEmptyOptionalToolArgs(parsed, toolName);
return JSON.stringify(cleaned ?? {});
} catch {
return value;
}
}
if (Array.isArray(value) || typeof value !== "object") return value;
const cleaned = { ...value };
for (const [key, entry] of Object.entries(cleaned)) {
if (entry === "" || (Array.isArray(entry) && entry.length === 0)) {
delete cleaned[key];
}
}
return cleaned;
}
// normalizeUpstreamFailure is re-exported for external importers (tests).
export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts";
/**
* Translate OpenAI chunk to Responses API events
@@ -192,11 +170,6 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
}
// Normalize output_index to a non-negative integer (replaces fragile parseInt calls)
function normalizeOutputIndex(outputIndex) {
const normalized = Number(outputIndex);
return Number.isInteger(normalized) && normalized >= 0 ? normalized : 0;
}
// Record a finalized item keyed by output_index so buildDenseOutput can sort later
function recordCompletedItem(state, outputIndex, item) {
if (!Array.isArray(state.completedOutputItems)) {
@@ -564,50 +537,6 @@ function flushEvents(state) {
return events;
}
export function normalizeUpstreamFailure(data, fallbackType = "server_error") {
const response = data?.response && typeof data.response === "object" ? data.response : null;
const error =
response?.error && typeof response.error === "object"
? response.error
: data?.error && typeof data.error === "object"
? data.error
: null;
const code = typeof error?.code === "string" ? error.code : "";
const message =
typeof error?.message === "string"
? error.message
: typeof data?.message === "string"
? data.message
: "Upstream failure";
// Preserve upstream error semantics:
// - context_length_exceeded → 400 (client can retry with smaller context)
// - rate_limit_exceeded → 429 (client should back off)
// - Everything else → 502 (upstream failure)
const isContextOverflow = code === "context_length_exceeded";
const isRateLimit = code === "rate_limit_exceeded" || code === "rate_limited";
let status: number;
let type: string;
if (isRateLimit) {
status = 429;
type = "rate_limit_error";
} else if (isContextOverflow) {
status = 400;
type = "invalid_request_error";
} else {
status = 502;
type = fallbackType;
}
return {
status,
type,
code: code || (isRateLimit ? "rate_limit_exceeded" : "bad_gateway"),
message,
};
}
/**
* OpenAI Chat Completions streams announce the assistant role on the FIRST delta
* (e.g. `{ "role": "assistant", "content": "" }` or `{ "role": "assistant",
@@ -680,15 +609,6 @@ function buildResponsesReasoningDeltaChunk(state, text) {
};
}
function extractResponsesReasoningSummaryText(item) {
if (!item || !Array.isArray(item.summary)) return "";
return item.summary
.map((part) =>
part && typeof part === "object" && typeof part.text === "string" ? part.text : ""
)
.join("");
}
/**
* Translate OpenAI Responses API chunk to OpenAI Chat Completions format
* This is for when Codex returns data and we need to send it to an OpenAI-compatible client

View File

@@ -0,0 +1,92 @@
// Pure, stateless helpers for the OpenAI Responses <-> Chat response translator.
// Extracted verbatim from response/openai-responses.ts (no host imports, no stream state).
export function normalizeToolName(value) {
return typeof value === "string" ? value.trim() : "";
}
export function stripEmptyOptionalToolArgs(value, toolName) {
if (value == null) return value;
if (typeof value === "string") {
// JSON-string cleanup is intentionally scoped to Claude Code's Read tool.
// For arbitrary tools, empty strings/arrays may be valid user payloads.
if (toolName !== "Read") return value;
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed) || typeof parsed !== "object" || parsed === null) return value;
const cleaned = stripEmptyOptionalToolArgs(parsed, toolName);
return JSON.stringify(cleaned ?? {});
} catch {
return value;
}
}
if (Array.isArray(value) || typeof value !== "object") return value;
const cleaned = { ...value };
for (const [key, entry] of Object.entries(cleaned)) {
if (entry === "" || (Array.isArray(entry) && entry.length === 0)) {
delete cleaned[key];
}
}
return cleaned;
}
export function normalizeOutputIndex(outputIndex) {
const normalized = Number(outputIndex);
return Number.isInteger(normalized) && normalized >= 0 ? normalized : 0;
}
export function normalizeUpstreamFailure(data, fallbackType = "server_error") {
const response = data?.response && typeof data.response === "object" ? data.response : null;
const error =
response?.error && typeof response.error === "object"
? response.error
: data?.error && typeof data.error === "object"
? data.error
: null;
const code = typeof error?.code === "string" ? error.code : "";
const message =
typeof error?.message === "string"
? error.message
: typeof data?.message === "string"
? data.message
: "Upstream failure";
// Preserve upstream error semantics:
// - context_length_exceeded → 400 (client can retry with smaller context)
// - rate_limit_exceeded → 429 (client should back off)
// - Everything else → 502 (upstream failure)
const isContextOverflow = code === "context_length_exceeded";
const isRateLimit = code === "rate_limit_exceeded" || code === "rate_limited";
let status: number;
let type: string;
if (isRateLimit) {
status = 429;
type = "rate_limit_error";
} else if (isContextOverflow) {
status = 400;
type = "invalid_request_error";
} else {
status = 502;
type = fallbackType;
}
return {
status,
type,
code: code || (isRateLimit ? "rate_limit_exceeded" : "bad_gateway"),
message,
};
}
export function extractResponsesReasoningSummaryText(item) {
if (!item || !Array.isArray(item.summary)) return "";
return item.summary
.map((part) =>
part && typeof part === "object" && typeof part.text === "string" ? part.text : ""
)
.join("");
}

View File

@@ -0,0 +1,59 @@
// ClinePass upstream wraps non-streaming JSON responses in a {success, data}
// envelope (errors use {success: false, error}). Detect and unwrap; pass the
// payload through untouched for every other provider / shape.
export interface ClinepassEnvelopeError {
message: string;
status: number | null;
}
export interface ClinepassEnvelopeResult {
body: unknown;
error: ClinepassEnvelopeError | null;
}
/**
* Unwrap a ClinePass {success, data} envelope.
*
* - Non-clinepass provider, non-object, array, or object without a `success`
* key → pass through untouched ({ body, error: null }).
* - { success: false, ... } → { body: null, error: { message, status } } with
* the upstream error string extracted (never a local stack — the caller must
* still route it through sanitizeErrorMessage before emitting a response).
* - { success: true, data: {...} } → unwrap to `data`.
*/
export function unwrapClinepassEnvelope(
body: unknown,
provider: string | null | undefined
): ClinepassEnvelopeResult {
if (provider !== "clinepass") return { body, error: null };
if (!body || typeof body !== "object" || Array.isArray(body)) return { body, error: null };
const record = body as Record<string, unknown>;
if (!("success" in record)) return { body, error: null };
if (record.success === false) {
const rawError = record.error;
const message =
typeof rawError === "string"
? rawError
: (rawError && typeof rawError === "object"
? ((rawError as Record<string, unknown>).message as string | undefined)
: undefined) ||
(typeof record.message === "string" ? record.message : undefined) ||
"Upstream error";
const statusCode = typeof record.statusCode === "number" ? record.statusCode : null;
return { body: null, error: { message, status: statusCode } };
}
if (
record.success === true &&
"data" in record &&
record.data !== null &&
typeof record.data === "object"
) {
return { body: record.data, error: null };
}
return { body, error: null };
}

View File

@@ -1,4 +1,5 @@
import { CORS_HEADERS } from "./cors.ts";
import { unwrapClinepassEnvelope } from "./clinepassEnvelope.ts";
import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts";
import { normalizePayloadForLog } from "@/lib/logPayloads";
import type { ModelCooldownErrorPayload } from "@/types";
@@ -230,7 +231,14 @@ export async function parseUpstreamError(response: Response, provider: string |
const parsed = JSON.parse(text);
// Handle array responses (e.g., from some Gemini APIs)
const json = (Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : parsed) || {};
message = json.error?.message || json.message || json.error || text;
// ClinePass wraps upstream errors in a {success:false, error} envelope.
// Extract the upstream error string (an upstream JSON field, not a local
// stack) — still routed through sanitizeErrorMessage/buildErrorBody by
// every consumer below (Rule #12).
const { error: clinepassEnvError } = unwrapClinepassEnvelope(json, provider);
message = clinepassEnvError
? clinepassEnvError.message
: json.error?.message || json.message || json.error || text;
errorCode = json.error?.code || json.code;
errorType = json.error?.type || json.type;
} catch {
@@ -497,7 +505,8 @@ export function formatProviderError(
const message = error.message || "Unknown error";
// Expose low-level cause (e.g. UND_ERR_SOCKET, ECONNRESET, ETIMEDOUT) for diagnosing fetch failures
const cause = (error as { cause?: unknown }).cause;
const causeObj = cause && typeof cause === "object" ? (cause as Record<string, unknown>) : undefined;
const causeObj =
cause && typeof cause === "object" ? (cause as Record<string, unknown>) : undefined;
const causeCode = typeof causeObj?.code === "string" ? causeObj.code : undefined;
const causeMsg = typeof causeObj?.message === "string" ? causeObj.message : undefined;
const causeStr =

View File

@@ -53,6 +53,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
/**
* Matches ANSI/VT100 terminal control sequences plus non-whitespace C0 control
* codes, while preserving `\t` (0x09), `\n` (0x0a), and `\r` (0x0d).
*
* Some upstream CLIs (notably gemini-cli via the `gc/` bridge) prefix SSE frames
* with cursor-movement escapes such as `\x1b[2K\x1b[1A` to redraw the terminal.
* Those bytes are not whitespace, so `line.trimStart().startsWith("data:")` fails
* and the frame is silently dropped, stalling the client SSE parser (issue #2273).
*
* The pattern is strictly bounded (no unbounded quantifiers over overlapping
* alternatives) so it runs in linear time on untrusted input — ReDoS-safe.
*/
// eslint-disable-next-line no-control-regex
const ANSI_ESCAPE_RE =
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[A-Z\[\]\\^_`])|[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
/**
* Strip ANSI/VT100 escape sequences (and stray C0 controls) from a string.
* Non-string inputs (null/undefined) are returned unchanged. Preserves \t \n \r.
*/
export function stripAnsiCodes<T>(str: T): T {
if (typeof str !== "string") return str;
return str.replace(ANSI_ESCAPE_RE, "") as T;
}
export function parseSSEDataPayload(
data: unknown,
options: SSEPayloadOptions = {}
@@ -88,15 +113,18 @@ export function parseSSEDataLines(
export function parseSSELine(line: string): SSEJsonPayload | null {
if (!line) return null;
// Trim leading whitespace before checking field name.
// Trim leading whitespace before checking field name. Also strip ANSI/VT100
// escape codes so terminal-redraw-prefixed frames (e.g. gemini-cli `\x1b[2K\x1b[1A`)
// still resolve to a `data:` line instead of being silently dropped (#2273).
const trimmed = line.trimStart();
if (!trimmed.startsWith("data:")) return null;
const clean = stripAnsiCodes(trimmed);
if (!clean.startsWith("data:")) return null;
return parseSSEDataPayload(trimmed.slice(5));
return parseSSEDataPayload(clean.slice(5));
}
function extractSseDataLine(line: string): string | null {
const trimmed = line.trimStart().replace(/\r$/, "");
const trimmed = stripAnsiCodes(line.trimStart().replace(/\r$/, ""));
if (!trimmed.startsWith("data:")) return null;
return trimmed.slice(5).trimStart();
}

View File

@@ -205,7 +205,9 @@
"uninstall:full": "node scripts/build/uninstall.mjs --full",
"prepare": "husky",
"system-info": "node scripts/dev/system-info.mjs",
"build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs"
"build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs",
"release:contributors": "node scripts/release/gen-contributors.mjs",
"release:uncovered": "node scripts/release/list-uncovered-commits.mjs"
},
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.1073.0",

View File

@@ -231,7 +231,16 @@ if (isMain) {
} else if (result === "pass") {
reportLines.push("Result: PASS", "", reason);
} else {
reportLines.push("Result: FAIL", "", reason);
reportLines.push(
"Result: FAIL",
"",
reason,
"",
"> Editing the PR body to add the evidence does NOT re-run this gate — `ci.yml` " +
"does not listen to the `edited` event. Add the `## Evidence` block, then **push a " +
"commit** (or re-run this job) to re-validate. For releases, put the Evidence block in " +
"the body BEFORE the first push (see the generate-release skill, Phase 0)."
);
}
const report = buildReport(reportLines);

View File

@@ -146,7 +146,7 @@ function run(cmd, cmdArgs, opts = {}) {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 256 * 1024 * 1024,
env: { ...process.env, FORCE_COLOR: "0" },
env: { ...process.env, FORCE_COLOR: "0", ...(opts.env || {}) },
// A hard ceiling for the long, silent test suites (execFileSync buffers all output until
// exit, so they show no progress while running). undefined = no timeout for fast gates.
...(opts.timeout ? { timeout: opts.timeout } : {}),
@@ -255,6 +255,27 @@ function main() {
});
}
// test-masking (hard) — a PR-context gate: it only runs on the release PR (PR→main) in CI, so
// net-assert reductions accrue unseen on release/** and explode on the release PR. Reproduce it
// here against origin/main so a non-allowlisted reduction surfaces in the pre-flight, not in a
// ~40-min CI layer (v3.8.43 cost 3 such round-trips). Legitimate reductions get allowlisted in
// config/quality/test-masking-allowlist.json; tautology/skip/deletion signals are never allowlistable.
if (!QUICK) {
announce("Test-masking (weakened-assert guard vs main)");
// best-effort fetch so the merge-base diff is accurate; ignore fetch failure (offline pre-flight)
run("git", ["fetch", "--no-tags", "origin", "main", "--depth=200"], { timeout: 60 * 1000 });
const { code, out } = run(npmCmd, ["run", "check:test-masking"], {
env: { GITHUB_BASE_REF: "main" },
});
record({
id: "test-masking",
label: "Test-masking (weakened-assert guard)",
kind: "hard",
ok: code === 0,
detail: code === 0 ? "no weakening" : firstFailureLine(out),
});
}
// Remaining quality-gate / quality-extended ratchets that the PR→release
// fast-gates skip and that historically surfaced — one at a time, because the
// CI Quality Ratchet job is fail-fast — only on the release PR. Running them all

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env node
// Generate (or inject) the `### 🙌 Contributors` table for a CHANGELOG version section.
//
// WHY: every version's CHANGELOG `## [vX.Y.Z]` section MUST end with a `### 🙌 Contributors`
// table (the convention across every prior version). v3.8.43 shipped without it (a real miss the
// owner caught) because it was assembled by hand. This makes it reproducible + accurate.
//
// A naive `@handle` scan mis-assigns rollup PRs — a maintenance bullet lists many PRs under one
// `— thanks @X`, and a flat scan would credit every handle on the line with all of them. This
// parses each `([#refs] — thanks @X / @Y)` PARENTHETICAL GROUP and assigns that group's refs only
// to that group's handles (crediting is per-parenthetical, matching how bullets are written).
//
// Usage:
// node scripts/release/gen-contributors.mjs <version> # print the table
// node scripts/release/gen-contributors.mjs <version> --inject # insert/replace it in CHANGELOG.md
//
// Exit codes: 0 ok · 2 version section not found · 3 nothing to inject over.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
// Handles that are package names / code refs / scopes, never people. Extend as needed.
export const NOISE_HANDLES = new Set([
"toon-format",
"dnd-kit",
"om-usage",
"anthropic-ai",
"huggingface",
"oven",
"latest",
"next",
"types",
]);
const MAINTAINER = "diegosouzapw";
/** Extract the `## [version]` … up to the next `## [` section body (exclusive of the next header). */
export function extractVersionSection(changelog, version) {
const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const startRe = new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m");
const sm = changelog.match(startRe);
if (!sm) return null;
const bodyStart = sm.index + sm[0].length;
const rest = changelog.slice(bodyStart);
const nextIdx = rest.search(/\n## \[/);
return nextIdx === -1 ? rest : rest.slice(0, nextIdx);
}
/**
* Parse contributor → set of ref numbers from a version section body.
* Rules (in order, per bullet line starting with "- "):
* 1. Parenthetical groups containing "thanks": refs in the group → handles in the group.
* 2. A "thanks @X" NOT inside such a group (direct-commit trailing credit): the last ref before
* it on the line (if any) → the handles.
* 3. "Extracted from [#N] by [@X]": N → X.
* Excludes NOISE_HANDLES and the maintainer (returned separately by caller).
*/
export function parseContributors(sectionText) {
const agg = new Map(); // handle -> Set(refs)
const add = (handle, refs) => {
if (NOISE_HANDLES.has(handle) || handle === MAINTAINER) return;
if (!agg.has(handle)) agg.set(handle, new Set());
for (const r of refs) agg.get(handle).add(r);
};
const handlesIn = (s) => [...s.matchAll(/@([A-Za-z0-9_-]+)/g)].map((m) => m[1]);
const refsIn = (s) => [...s.matchAll(/#(\d+)/g)].map((m) => Number(m[1]));
for (const raw of sectionText.split("\n")) {
if (!raw.startsWith("- ")) continue;
// Collapse markdown links so parenthetical groups aren't broken by the URL's own parens:
// [#5720](https://…/pull/5720) → #5720 · [@pizzav-xyz](https://…) → @pizzav-xyz
const line = raw
.replace(/\[#(\d+)\]\([^)]*\)/g, "#$1")
.replace(/\[@([A-Za-z0-9_-]+)\]\([^)]*\)/g, "@$1");
const usedSpans = [];
// (1) parenthetical groups with "thanks"
for (const g of line.matchAll(/\(([^()]*thanks[^()]*)\)/g)) {
const inner = g[1];
const refs = refsIn(inner);
for (const th of inner.matchAll(/thanks\s+((?:@[A-Za-z0-9_-]+(?:\s*\/\s*)?)+)/g)) {
for (const h of handlesIn(th[1])) add(h, refs);
}
usedSpans.push([g.index, g.index + g[0].length]);
}
// (2) trailing "— thanks @X" outside any used parenthetical (direct commits)
for (const th of line.matchAll(/thanks\s+((?:@[A-Za-z0-9_-]+(?:\s*\/\s*)?)+)/g)) {
const inGroup = usedSpans.some(([s, e]) => th.index >= s && th.index < e);
if (inGroup) continue;
const before = line.slice(0, th.index);
const refsBefore = refsIn(before);
const refs = refsBefore.length ? [refsBefore[refsBefore.length - 1]] : [];
for (const h of handlesIn(th[1])) add(h, refs);
}
// (3) "Extracted from #N by @X" (links already collapsed by the preprocessing above)
for (const em of line.matchAll(/[Ee]xtracted from #(\d+)\s+by\s+@([A-Za-z0-9_-]+)/g)) {
add(em[2], [Number(em[1])]);
}
}
return agg;
}
export function renderContributors(version, agg, maintainerNote = "maintainer") {
const fmt = (set) =>
set.size
? [...set]
.sort((a, b) => a - b)
.map((n) => `#${n}`)
.join(", ")
: "direct commit / report";
const rows = [...agg.entries()].sort((a, b) =>
a[0].toLowerCase().localeCompare(b[0].toLowerCase())
);
const lines = [
"### 🙌 Contributors",
"",
`Thanks to everyone whose work landed in v${version}:`,
"",
"| Contributor | PRs / Issues |",
"| --- | --- |",
];
for (const [h, refs] of rows) {
lines.push(`| [@${h}](https://github.com/${h}) | ${fmt(refs)} |`);
}
lines.push(`| [@${MAINTAINER}](https://github.com/${MAINTAINER}) | ${maintainerNote} |`);
return lines.join("\n");
}
/** Insert or replace the Contributors section inside the version block, before its closing `---`. */
export function injectContributors(changelog, version, table) {
const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const startRe = new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m");
const sm = changelog.match(startRe);
if (!sm) return null;
const headerEnd = sm.index + sm[0].length;
const rest = changelog.slice(headerEnd);
const nextIdx = rest.search(/\n## \[/);
const bodyEnd = nextIdx === -1 ? changelog.length : headerEnd + nextIdx;
let body = changelog.slice(headerEnd, bodyEnd);
// strip an existing Contributors section (idempotent re-run)
body = body.replace(/\n### 🙌 Contributors[\s\S]*?(?=\n---\n|$)/, "\n");
// insert before the trailing `---` (or append if none)
const idx = body.lastIndexOf("\n---");
const insertion = `\n${table}\n`;
body = idx >= 0 ? body.slice(0, idx) + insertion + body.slice(idx) : `${body}${insertion}\n---\n`;
return changelog.slice(0, headerEnd) + body + changelog.slice(bodyEnd);
}
function main(argv) {
const version = argv[0];
const inject = argv.includes("--inject");
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
process.stderr.write("usage: gen-contributors.mjs <version> [--inject]\n");
process.exit(1);
}
const clPath = path.join(ROOT, "CHANGELOG.md");
const changelog = fs.readFileSync(clPath, "utf8");
const section = extractVersionSection(changelog, version);
if (section == null) {
process.stderr.write(`No [${version}] section in CHANGELOG.md\n`);
process.exit(2);
}
const agg = parseContributors(section);
const table = renderContributors(version, agg);
if (!inject) {
process.stdout.write(table + "\n");
return;
}
const next = injectContributors(changelog, version, table);
if (next == null) {
process.stderr.write(`Could not locate [${version}] block for injection\n`);
process.exit(3);
}
fs.writeFileSync(clPath, next);
process.stderr.write(`✓ Injected ${agg.size} external contributor(s) into [${version}]\n`);
}
// direct-run guard (importable for tests)
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main(process.argv.slice(2));
}

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env node
// Reconciliation helper: list non-merge commits since the last tag whose PR/issue ref is NOT
// represented in the current version's CHANGELOG section (or [Unreleased]).
//
// WHY: during the cycle, PRs merge into release/** and some land WITHOUT a CHANGELOG bullet, so
// /generate-release reconciliation has to rediscover them by hand (v3.8.43: 123 of 176 commits had
// no bullet). This surfaces exactly that gap in seconds — maintainer-side, non-blocking, run it at
// reconciliation (Phase 0a) so the release CHANGELOG is complete before the PR opens.
//
// A commit is "covered" iff ANY `#N` in its subject appears anywhere in the CHANGELOG scan window
// (the version section + [Unreleased]) — matching on issue OR PR number, since a bullet may cite
// either. Internal commits (chore/ci/test/refactor) are listed under "rollup candidates" so the
// maintainer can consolidate rather than write one bullet each.
//
// Usage: node scripts/release/list-uncovered-commits.mjs [--json]
// Exit: 0 always (advisory). Prints a report to stdout.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const git = (args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
const ROLLUP_TYPES = new Set(["chore", "ci", "test", "refactor", "build", "docs", "style"]);
export function refsOf(subject) {
return [...subject.matchAll(/#(\d+)/g)].map((m) => Number(m[1]));
}
export function typeOf(subject) {
const m = subject.match(/^([a-z]+)(\(|:|!)/);
return m ? m[1] : "other";
}
/**
* @param {{hash:string, subject:string}[]} commits
* @param {Set<number>} changelogRefs every #N present in the CHANGELOG scan window
* @returns {{covered:number, uncovered:{hash,subject,refs,type,rollup}[]}}
*/
export function computeUncovered(commits, changelogRefs) {
const uncovered = [];
let covered = 0;
for (const c of commits) {
const refs = refsOf(c.subject);
const isCovered = refs.length > 0 && refs.some((r) => changelogRefs.has(r));
if (isCovered) {
covered++;
} else {
const type = typeOf(c.subject);
uncovered.push({ ...c, refs, type, rollup: ROLLUP_TYPES.has(type) });
}
}
return { covered, uncovered };
}
/** Read every #N in the version's CHANGELOG section + the [Unreleased] section. */
export function changelogRefWindow(changelog, version) {
const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// From [Unreleased] up to (but excluding) the version-after-this one.
const startRe = /^## \[Unreleased\]/m;
const s = changelog.match(startRe);
const from = s ? s.index : 0;
// find the header AFTER the target version
const verRe = new RegExp(`^## \\[${esc}\\]`, "m");
const vm = changelog.slice(from).match(verRe);
const afterVersionStart = vm ? from + vm.index + vm[0].length : from;
const rest = changelog.slice(afterVersionStart);
const nextIdx = rest.search(/\n## \[/);
const to = nextIdx === -1 ? changelog.length : afterVersionStart + nextIdx;
const window = changelog.slice(from, to);
return new Set([...window.matchAll(/#(\d+)/g)].map((m) => Number(m[1])));
}
function main(argv) {
const jsonOut = argv.includes("--json");
const lastTag = git(["describe", "--tags", "--abbrev=0"]);
const version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
const log = git(["log", "--no-merges", `${lastTag}..HEAD`, "--pretty=format:%h%x09%s"]);
const commits = log
? log.split("\n").map((l) => {
const [hash, subject] = l.split("\t");
return { hash, subject };
})
: [];
const changelog = fs.readFileSync(path.join(ROOT, "CHANGELOG.md"), "utf8");
const refs = changelogRefWindow(changelog, version);
const { covered, uncovered } = computeUncovered(commits, refs);
if (jsonOut) {
process.stdout.write(
JSON.stringify({ version, lastTag, total: commits.length, covered, uncovered }, null, 2) +
"\n"
);
return;
}
const bulletsWorthy = uncovered.filter((c) => !c.rollup);
const rollupCandidates = uncovered.filter((c) => c.rollup);
process.stdout.write(`# Uncovered-commit reconciliation — v${version} (${lastTag}..HEAD)\n\n`);
process.stdout.write(
`Commits: ${commits.length} · covered: ${covered} · uncovered: ${uncovered.length}\n\n`
);
process.stdout.write(
`## Needs a bullet (feat/fix/other — user-facing) — ${bulletsWorthy.length}\n`
);
for (const c of bulletsWorthy) process.stdout.write(`- ${c.hash} ${c.subject}\n`);
process.stdout.write(
`\n## Rollup candidates (chore/ci/test/refactor/docs) — ${rollupCandidates.length}\n`
);
for (const c of rollupCandidates) process.stdout.write(`- ${c.hash} ${c.subject}\n`);
process.stdout.write(
`\n> Advisory. Add a bullet for each user-facing item; consolidate rollup candidates into a few Maintenance bullets (list their PR numbers).\n`
);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main(process.argv.slice(2));
}

View File

@@ -1,6 +1,7 @@
import { getAntigravityModelsDiscoveryUrls } from "@omniroute/open-sse/config/antigravityUpstream.ts";
import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts";
import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser";
import { filterClinepassModels } from "@omniroute/open-sse/services/clinepassModels.ts";
import { normalizeOpenAiLikeModelsResponse } from "./normalizers";
export type ProviderModelsConfigEntry = {
@@ -246,6 +247,16 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
// ClinePass (BYOK apikey gateway) — same host as OAuth `cline`, but only the
// `cline-pass/*` namespace is surfaced (filterClinepassModels).
clinepass: {
url: "https://api.cline.bot/api/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => filterClinepassModels(Array.isArray(data) ? data : data?.data),
},
cohere: {
url: "https://api.cohere.com/v2/models",
method: "GET",

View File

@@ -533,7 +533,14 @@ export async function GET(
base = base.slice(0, -17);
} else if (base.endsWith("/completions")) {
base = base.slice(0, -12);
} else if (base.endsWith("/v1")) {
}
// Strip trailing /v1 unconditionally so the next step re-adds it exactly once.
// Without this, baseUrls that embed /v1 (e.g. "https://api.airforce/v1/chat/completions")
// become "…/v1" after stripping "/chat/completions", and then appending "/v1/models"
// produces "…/v1/v1/models" — a 308 redirect that blocked model fetch (#5899).
// Guard against a literal "scheme://v1" authority so we never strip the host itself.
if (base.endsWith("/v1") && !base.endsWith("://v1")) {
base = base.slice(0, -3);
}
@@ -576,6 +583,11 @@ export async function GET(
}
} catch (err: any) {
if (err.message === "auth_failed") break; // Don't try other endpoints if auth failed
if (err?.code === "REDIRECT_BLOCKED") {
continue; // Try next endpoint
}
const status = getSafeOutboundFetchErrorStatus(err);
if (status) {
throw err;
@@ -1717,7 +1729,11 @@ export async function GET(
base = base.slice(0, -"/chat/completions".length);
} else if (base.endsWith("/completions")) {
base = base.slice(0, -"/completions".length);
} else if (base.endsWith("/v1")) {
}
// Strip a trailing /v1 unconditionally (same #5899 double-prefix guard as the
// discovery path above): a customBaseUrl like ".../v1/chat/completions" would
// otherwise leave base as ".../v1" and produce ".../v1/v1/models" below.
if (base.endsWith("/v1") && !base.endsWith("://v1")) {
base = base.slice(0, -"/v1".length);
}
url = `${base}/v1/models`;

View File

@@ -3,8 +3,6 @@ import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
import { providerChatCompletionSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
let initialized = false;
@@ -30,6 +28,7 @@ export async function OPTIONS() {
/**
* POST /v1/providers/{provider}/chat/completions
* Routes to the specified provider, validating model/provider match.
* Full body format validation is delegated to handleChat.
*/
export async function POST(request, { params }) {
const { provider: rawProvider } = await params;
@@ -45,18 +44,25 @@ export async function POST(request, { params }) {
await ensureInitialized();
// Clone request with provider-prefixed model
let rawBody;
// Parse body once so this provider-scoped route can normalize the model prefix
// before delegating full chat-format validation to handleChat.
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
const validation = validateBody(providerChatCompletionSchema, rawBody);
if (isValidationFailure(validation)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
if (!rawBody || typeof rawBody !== "object" || Array.isArray(rawBody)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Request body must be a JSON object");
}
const body = rawBody as { model?: string; [key: string]: unknown };
// Keep the route-level checks minimal: only guard fields needed for provider prefix handling.
if (body.model !== undefined && typeof body.model !== "string") {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "model must be a string");
}
const body = validation.data;
// Validate model belongs to this provider
if (body.model) {

View File

@@ -250,15 +250,20 @@ export function setQuotaCache(
}
: null,
});
// #5923 (Finding #5) — is_exhausted must reflect THIS window's own remaining
// percentage, not the connection-wide AND-across-all-windows aggregate
// (`entry.exhausted`). A connection with one 0% window and other non-zero
// windows previously never flagged that window's row as exhausted.
const windowExhausted = remainingPercentage <= 0;
// #4438 — only persist on the first observation or a real change.
if (!quotaSnapshotChanged(prior, windowKey, remainingPercentage, entry.exhausted)) continue;
if (!quotaSnapshotChanged(prior, windowKey, remainingPercentage, windowExhausted)) continue;
try {
saveQuotaSnapshot({
provider,
connection_id: connectionId,
window_key: windowKey,
remaining_percentage: remainingPercentage,
is_exhausted: entry.exhausted ? 1 : 0,
is_exhausted: windowExhausted ? 1 : 0,
next_reset_at: quotaInfo.resetAt ?? null,
window_duration_ms: entry.windowDurationMs ?? null,
raw_data: null,

View File

@@ -264,6 +264,39 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
// ── Specialty provider validation ──
const SPECIALTY_VALIDATORS = {
"v0-vercel": async ({ apiKey, providerSpecificData }: any) => {
try {
const configuredBaseUrl =
typeof providerSpecificData?.baseUrl === "string" && providerSpecificData.baseUrl.trim()
? providerSpecificData.baseUrl.trim()
: "https://api.v0.dev";
const root = normalizeBaseUrl(configuredBaseUrl)
.replace(/\/v1\/chat\/completions$/, "")
.replace(/\/v1$/, "");
const res = await validationRead(
`${root}/v1/chats?limit=1`,
{
method: "GET",
headers: buildBearerHeaders(apiKey, providerSpecificData),
},
isLocal
);
if (res.ok) {
return { valid: true, error: null, method: "v0_platform_chats_list" };
}
if (res.status === 401 || res.status === 403) {
return { valid: false, error: "Invalid API key" };
}
return { valid: false, error: `v0 validation failed: ${res.status}` };
} catch (error: any) {
return toValidationErrorResult(error);
}
},
jules: validateJulesProvider,
qoder: async ({ apiKey, providerSpecificData }: any) => {
// Bifurcate validation: PAT tokens use Cosy auth against api1.qoder.sh;

View File

@@ -28,6 +28,20 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
"Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint.",
apiHint: "Create or copy an API key from Command Code, then paste it here as a Bearer token.",
},
clinepass: {
id: "clinepass",
alias: "clinepass",
name: "ClinePass",
icon: "vpn_key",
color: "#5B9BD5",
textIcon: "CP",
passthroughModels: true,
website: "https://cline.bot",
notice: {
text: "ClinePass is Cline's paid BYOK gateway ($9.99/mo). Bring your own Cline API key; requests hit api.cline.bot with the cline-pass/* model namespace.",
apiKeyUrl: "https://app.cline.bot/settings/api-keys",
},
},
openrouter: {
id: "openrouter",
alias: "openrouter",

View File

@@ -6,10 +6,6 @@ import {
updateProviderConnection,
getSettings,
getCachedSettings,
getSessionAccountAffinity,
upsertSessionAccountAffinity,
touchSessionAccountAffinity,
deleteSessionAccountAffinity,
} from "@/lib/localDb";
import {
DEFAULT_QUOTA_THRESHOLD_PERCENT,
@@ -60,6 +56,12 @@ import {
WEB_COOKIE_PROVIDERS,
} from "@/shared/constants/providers";
import { isModelExcludedByConnection } from "@/domain/connectionModelRules";
import {
applySessionAffinityPin,
formatSessionKeyForLog,
resolveSessionAffinityTtlMs,
selectSessionAffinityConnection,
} from "./sessionAffinityPin";
import { isNoAuthProviderBlockedBySettings } from "./noAuthProviderSettings";
import { resolveAccountProxiesFromRegistry } from "./noAuthProxyResolution";
import * as log from "../utils/logger";
@@ -304,10 +306,6 @@ export function extractSessionAffinityKey(
return `input:sha256:${createHash("sha256").update(inputText.slice(0, 4096)).digest("hex")}`;
}
function formatSessionKeyForLog(sessionKey: string): string {
return `${sessionKey.slice(0, 18)}...`;
}
function getCodexLimitPolicy(providerSpecificData: JsonRecord): {
use5h: boolean;
useWeekly: boolean;
@@ -713,69 +711,6 @@ function compareP2CConnections(
return a.id.localeCompare(b.id);
}
function compareLruConnections(a: ProviderConnectionView, b: ProviderConnectionView): number {
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
if (!a.lastUsedAt) return -1;
if (!b.lastUsedAt) return 1;
const recencyDelta = new Date(a.lastUsedAt).getTime() - new Date(b.lastUsedAt).getTime();
if (recencyDelta !== 0) return recencyDelta;
if ((a.consecutiveUseCount || 0) !== (b.consecutiveUseCount || 0)) {
return (a.consecutiveUseCount || 0) - (b.consecutiveUseCount || 0);
}
return (a.priority || 999) - (b.priority || 999);
}
async function selectSessionAffinityConnection(
provider: string,
sessionKey: string | null | undefined,
connections: ProviderConnectionView[],
ttlMs = 0
): Promise<ProviderConnectionView | null> {
if (!sessionKey || connections.length === 0 || ttlMs <= 0) return null;
const existing = getSessionAccountAffinity(sessionKey, provider, ttlMs);
if (existing) {
const connection = connections.find((candidate) => candidate.id === existing.connectionId);
if (connection) {
touchSessionAccountAffinity(sessionKey, provider, Date.now(), ttlMs);
await updateProviderConnection(connection.id, {
lastUsedAt: new Date().toISOString(),
consecutiveUseCount: (connection.consecutiveUseCount || 0) + 1,
});
log.info(
"AUTH",
`session_key=${formatSessionKeyForLog(sessionKey)} -> connection ${connection.id.slice(
0,
8
)} (affinity)`
);
return connection;
}
deleteSessionAccountAffinity(sessionKey, provider);
log.info(
"AUTH",
`affinity cleared for session_key=${formatSessionKeyForLog(sessionKey)} provider=${provider}`
);
}
const connection = [...connections].sort(compareLruConnections)[0] ?? null;
if (!connection) return null;
upsertSessionAccountAffinity(sessionKey, provider, connection.id, Date.now(), ttlMs);
await updateProviderConnection(connection.id, {
lastUsedAt: new Date().toISOString(),
consecutiveUseCount: 1,
});
log.info(
"AUTH",
`new affinity created for session_key=${formatSessionKeyForLog(
sessionKey
)} -> connection ${connection.id.slice(0, 8)}`
);
return connection;
}
/**
* Sentinel connection id used for the synthetic credentials of no-auth /
* keyless providers. It is NOT a real DB row, so it
@@ -1083,7 +1018,7 @@ export async function getProviderCredentials(
const allowRateLimitedConnections =
allowSuppressedConnections || options.allowRateLimitedConnections === true;
const bypassQuotaPolicy = options.bypassQuotaPolicy === true;
const forcedConnectionId =
let forcedConnectionId =
typeof options.forcedConnectionId === "string" && options.forcedConnectionId.trim().length > 0
? options.forcedConnectionId.trim()
: null;
@@ -1092,6 +1027,11 @@ export async function getProviderCredentials(
options.excludeConnectionIds
);
// Fetched early so the session-affinity-pin override (#5903) can consult
// the TTL before forcedConnectionId narrows the connection pool.
const settings = await getSettings();
const sessionAffinityTtlMs = resolveSessionAffinityTtlMs(provider, options, settings);
// Fix #922: Check for aliases (nvidia/nvidia_nim) to ensure credentials are found
const providersToSearch = await getProviderSearchPool(provider);
const connectionResults = await Promise.all(
@@ -1106,6 +1046,24 @@ export async function getProviderCredentials(
if (allowedConnections && allowedConnections.length > 0) {
connections = connections.filter((conn) => allowedConnections.includes(conn.id));
}
// #5903: an active session-affinity pin outranks a per-request reset-aware
// forcedConnectionId (see sessionAffinityPin leaf for the full rationale).
forcedConnectionId =
applySessionAffinityPin({
forcedConnectionId,
options,
sessionAffinityTtlMs,
connections,
provider,
requestedModel,
excludedConnectionIds,
isTerminalConnectionStatus,
isCodexScopeUnavailable,
isQuotaPolicyBlocked: (c) =>
evaluateQuotaLimitPolicy(provider, c as ProviderConnectionView, requestedModel).blocked,
}) ?? forcedConnectionId;
if (forcedConnectionId) {
connections = connections.filter((conn) => conn.id === forcedConnectionId);
}
@@ -1483,18 +1441,7 @@ export async function getProviderCredentials(
const orderedConnections = withQuota;
const settings = await getSettings();
const strategy = settings.fallbackStrategy || "fill-first";
const sessionAffinityTtlMs =
provider === "codex"
? Number.isFinite(Number(options.sessionAffinityTtlMs)) &&
Number(options.sessionAffinityTtlMs) > 0
? Number(options.sessionAffinityTtlMs)
: Number.isFinite(Number(settings.codexSessionAffinityTtlMs)) &&
Number(settings.codexSessionAffinityTtlMs) > 0
? Number(settings.codexSessionAffinityTtlMs)
: 0
: 0;
let connection;
const affinityConnection = await selectSessionAffinityConnection(

View File

@@ -0,0 +1,247 @@
/**
* #5903 — session-affinity-pin resolution + TTL, extracted from auth.ts as a
* pure leaf so the frozen god-file `auth.ts` does not grow.
*
* Problem: reset-aware (and other quota-scoring) combo strategies recompute a
* "winner" connection on every request and hand it to getProviderCredentials
* as `forcedConnectionId`. That id narrows the connection pool to exactly one
* connection BEFORE session affinity is consulted, so an existing pin pointing
* at a previously-selected account is never found and gets silently
* deleted/re-pinned to the fresh winner — breaking "same session -> reuse
* pinned account".
*
* Fix: when an active, non-expired affinity pin already exists for this
* (session, provider) AND the pinned connection is still eligible, the pin wins
* over the freshly recomputed `forcedConnectionId`. If the pin is ineligible
* (rate-limited / exhausted / model-locked / etc.) the caller keeps its forced
* connection, so the existing 429-driven `deleteSessionAccountAffinity`
* failover still owns rotating away from a pin that stops working.
*
* This module stays decoupled from auth.ts internals: the three predicates that
* live in (or would cause a cycle back into) auth.ts —
* `isTerminalConnectionStatus`, `isCodexScopeUnavailable`, and the quota-policy
* check wrapping `evaluateQuotaLimitPolicy` — are injected as callbacks.
*/
import {
getSessionAccountAffinity,
upsertSessionAccountAffinity,
touchSessionAccountAffinity,
deleteSessionAccountAffinity,
} from "@/lib/db/sessionAccountAffinity";
import { updateProviderConnection } from "@/lib/db/providers";
import { isModelExcludedByConnection } from "@/domain/connectionModelRules";
import { isAccountQuotaExhausted } from "@/domain/quotaCache";
import {
isAccountUnavailable,
isModelLocked,
} from "@omniroute/open-sse/services/accountFallback.ts";
import * as log from "../utils/logger";
/** Minimal structural view of a provider connection this module reads. */
export interface AffinityPinConnection {
id: string;
testStatus?: string | null;
rateLimitedUntil?: string | null;
providerSpecificData?: unknown;
}
/** Fields the LRU tie-break / session-affinity selection reads. */
export interface SessionAffinityConnection {
id: string;
lastUsedAt?: string | null;
consecutiveUseCount?: number | null;
priority?: number | null;
}
export function formatSessionKeyForLog(sessionKey: string): string {
return `${sessionKey.slice(0, 18)}...`;
}
function compareLruConnections(a: SessionAffinityConnection, b: SessionAffinityConnection): number {
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
if (!a.lastUsedAt) return -1;
if (!b.lastUsedAt) return 1;
const recencyDelta = new Date(a.lastUsedAt).getTime() - new Date(b.lastUsedAt).getTime();
if (recencyDelta !== 0) return recencyDelta;
if ((a.consecutiveUseCount || 0) !== (b.consecutiveUseCount || 0)) {
return (a.consecutiveUseCount || 0) - (b.consecutiveUseCount || 0);
}
return (a.priority || 999) - (b.priority || 999);
}
/**
* Session-affinity account selection (moved from auth.ts alongside the #5903
* pin-override so all session-affinity logic lives in one leaf). Reuses an
* active pin when its connection is in the pool; otherwise picks the LRU
* connection and creates a fresh pin. Behavior byte-identical to the original.
*/
export async function selectSessionAffinityConnection<T extends SessionAffinityConnection>(
provider: string,
sessionKey: string | null | undefined,
connections: T[],
ttlMs = 0
): Promise<T | null> {
if (!sessionKey || connections.length === 0 || ttlMs <= 0) return null;
const existing = getSessionAccountAffinity(sessionKey, provider, ttlMs);
if (existing) {
const connection = connections.find((candidate) => candidate.id === existing.connectionId);
if (connection) {
touchSessionAccountAffinity(sessionKey, provider, Date.now(), ttlMs);
await updateProviderConnection(connection.id, {
lastUsedAt: new Date().toISOString(),
consecutiveUseCount: (connection.consecutiveUseCount || 0) + 1,
});
log.info(
"AUTH",
`session_key=${formatSessionKeyForLog(sessionKey)} -> connection ${connection.id.slice(
0,
8
)} (affinity)`
);
return connection;
}
deleteSessionAccountAffinity(sessionKey, provider);
log.info(
"AUTH",
`affinity cleared for session_key=${formatSessionKeyForLog(sessionKey)} provider=${provider}`
);
}
const connection = [...connections].sort(compareLruConnections)[0] ?? null;
if (!connection) return null;
upsertSessionAccountAffinity(sessionKey, provider, connection.id, Date.now(), ttlMs);
await updateProviderConnection(connection.id, {
lastUsedAt: new Date().toISOString(),
consecutiveUseCount: 1,
});
log.info(
"AUTH",
`new affinity created for session_key=${formatSessionKeyForLog(
sessionKey
)} -> connection ${connection.id.slice(0, 8)}`
);
return connection;
}
/** Subset of credential-selection options the pin resolution consults. */
export interface AffinityPinOptions {
sessionKey?: string | null;
allowSuppressedConnections?: boolean;
allowRateLimitedConnections?: boolean;
bypassQuotaPolicy?: boolean;
sessionAffinityTtlMs?: number | null;
}
/** Settings subset needed to resolve the codex session-affinity TTL. */
export interface AffinityPinSettings {
codexSessionAffinityTtlMs?: number | null;
}
/**
* Resolve the effective session-affinity TTL. Only codex opts in today: an
* explicit per-request override wins, else the persisted codex setting, else 0
* (disabled). Kept here so auth.ts can reuse it at both the pin-override site
* and the downstream `selectSessionAffinityConnection` site with one call.
*/
export function resolveSessionAffinityTtlMs(
provider: string,
options: AffinityPinOptions,
settings: AffinityPinSettings
): number {
if (provider !== "codex") return 0;
const override = Number(options.sessionAffinityTtlMs);
if (Number.isFinite(override) && override > 0) return override;
const configured = Number(settings.codexSessionAffinityTtlMs);
if (Number.isFinite(configured) && configured > 0) return configured;
return 0;
}
/**
* Predicates supplied by the caller because they either live in auth.ts or
* would introduce a circular import if pulled in directly.
*/
export interface AffinityPinPredicates {
/** auth.ts::isTerminalConnectionStatus (banned/expired/credits_exhausted). */
isTerminalConnectionStatus: (connection: AffinityPinConnection) => boolean;
/** auth.ts::isCodexScopeUnavailable (codex per-scope cooldown). */
isCodexScopeUnavailable: (
connection: AffinityPinConnection,
requestedModel: string | null
) => boolean;
/** Wraps auth.ts::evaluateQuotaLimitPolicy(...).blocked for one connection. */
isQuotaPolicyBlocked: (connection: AffinityPinConnection) => boolean;
}
export interface ApplySessionAffinityPinParams extends AffinityPinPredicates {
forcedConnectionId: string | null;
options: AffinityPinOptions;
sessionAffinityTtlMs: number;
connections: AffinityPinConnection[];
provider: string;
requestedModel: string | null;
excludedConnectionIds: Set<string>;
}
/**
* Mirrors the eligibility predicates applied later in getProviderCredentials
* (availableConnections filter + quota policy + quota exhaustion) but scoped to
* a single candidate connection. Pure/read-only.
*/
function isConnectionEligibleForAffinityPin(
connection: AffinityPinConnection,
params: ApplySessionAffinityPinParams
): boolean {
const { provider, requestedModel, options } = params;
const allowSuppressed = options.allowSuppressedConnections === true;
const allowRateLimited = allowSuppressed || options.allowRateLimitedConnections === true;
if (params.excludedConnectionIds.has(connection.id)) return false;
if (
requestedModel &&
isModelExcludedByConnection(requestedModel, connection.providerSpecificData)
) {
return false;
}
if (!allowSuppressed) {
if (!allowRateLimited && isAccountUnavailable(connection.rateLimitedUntil)) return false;
if (params.isTerminalConnectionStatus(connection)) return false;
if (provider === "codex" && params.isCodexScopeUnavailable(connection, requestedModel)) {
return false;
}
if (requestedModel && isModelLocked(provider, connection.id, requestedModel)) return false;
}
if (isAccountQuotaExhausted(connection.id)) return false;
if (options.bypassQuotaPolicy !== true && params.isQuotaPolicyBlocked(connection)) return false;
return true;
}
/**
* If an active, non-expired affinity pin exists for (sessionKey, provider) and
* the pinned connection is present-and-eligible in the current pool, returns
* that pinned connectionId (which should override `forcedConnectionId`) and
* logs the override. Returns null when the caller should keep its
* `forcedConnectionId` — no session, TTL disabled, no pin, pin already equals
* the forced id, pin absent from pool, or pin ineligible.
*/
export function applySessionAffinityPin(params: ApplySessionAffinityPinParams): string | null {
const { forcedConnectionId, options, sessionAffinityTtlMs, connections, provider } = params;
const sessionKey = options.sessionKey;
if (!forcedConnectionId || !sessionKey || sessionAffinityTtlMs <= 0) return null;
const pinned = getSessionAccountAffinity(sessionKey, provider, sessionAffinityTtlMs);
if (!pinned || pinned.connectionId === forcedConnectionId) return null;
const pinnedConnection = connections.find((conn) => conn.id === pinned.connectionId);
if (!pinnedConnection || !isConnectionEligibleForAffinityPin(pinnedConnection, params)) {
return null;
}
log.info(
"AUTH",
`session affinity pin ${pinned.connectionId.slice(0, 8)}... overrides forcedConnectionId ${forcedConnectionId.slice(0, 8)}... (#5903)`
);
return pinned.connectionId;
}

View File

@@ -772,6 +772,35 @@
"stream": "https://api.cline.bot/api/v1/chat/completions"
}
},
"clinepass": {
"format": "openai",
"headers": {
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline"
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline"
}
},
"url": {
"nonStream": "https://api.cline.bot/api/v1/chat/completions",
"stream": "https://api.cline.bot/api/v1/chat/completions"
}
},
"cloudflare-ai": {
"format": "openai",
"headers": {

View File

@@ -0,0 +1,71 @@
/**
* Regression for #5899 (PR #5920): the OpenAI-compatible models-discovery URL
* builder must strip a trailing `/v1` UNCONDITIONALLY before appending
* `/v1/models`. A gateway baseUrl like ".../v1/chat/completions" was reduced to
* ".../v1" (the old `else if` skipped the /v1 strip once `/chat/completions`
* matched) and then produced ".../v1/v1/models" — a 308 redirect that blocked
* model discovery. The fix converts the `/v1` strip to an independent `if`
* (guarding against a literal "scheme://v1" authority) in BOTH the general
* discovery path and the `provider === "openai"` custom-base-URL path.
*/
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-5899-"));
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 modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts");
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("#5899 openai gateway baseUrl ending in /v1/chat/completions never probes /v1/v1/models", async () => {
const connection = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "airforce-gateway",
apiKey: "sk-airforce",
providerSpecificData: { baseUrl: "https://api.airforce/v1/chat/completions" },
});
const requestedUrls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const u = String(url);
requestedUrls.push(u);
// The correctly-stripped candidate must be the one that serves models.
if (u === "https://api.airforce/v1/models") {
return Response.json({ object: "list", data: [{ id: "gpt-4o" }, { id: "gpt-5" }] });
}
// The double-prefixed URL upstream answered with a 308 redirect (#5899).
if (u === "https://api.airforce/v1/v1/models") {
return new Response(null, { status: 308, headers: { location: u } });
}
return new Response("not found", { status: 404 });
};
try {
await modelsRoute.GET(
new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`),
{ params: { id: connection.id } }
);
} finally {
globalThis.fetch = originalFetch;
}
assert.ok(
requestedUrls.includes("https://api.airforce/v1/models"),
`expected a request to the correctly-stripped /v1/models URL; got: ${JSON.stringify(requestedUrls)}`
);
assert.ok(
!requestedUrls.includes("https://api.airforce/v1/v1/models"),
`must never probe the double-prefixed /v1/v1/models URL; got: ${JSON.stringify(requestedUrls)}`
);
});

View File

@@ -0,0 +1,55 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const mod = await import("../../scripts/check/check-pr-evidence.mjs");
const { evaluatePrBody } = mod;
const SCRIPT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../scripts/check/check-pr-evidence.mjs"
);
function run(body) {
try {
const out = execFileSync("node", [SCRIPT], {
encoding: "utf8",
env: { ...process.env, PR_BODY: body },
});
return { code: 0, out };
} catch (err) {
return { code: err.status ?? 1, out: `${err.stdout || ""}${err.stderr || ""}` };
}
}
test("evaluatePrBody: no outcome claim → pass (no evidence required)", () => {
const r = evaluatePrBody("Adds a helper module.");
assert.equal(r.result, "pass");
assert.match(r.reason, /no evidence required/i);
});
test("evaluatePrBody: outcome claim + evidence block → pass", () => {
const r = evaluatePrBody("Tests pass.\n\n## Evidence\n```\ntests 20 / pass 20 / fail 0\n```");
assert.equal(r.result, "pass");
});
test("evaluatePrBody: outcome claim without evidence → fail", () => {
const r = evaluatePrBody("All 20 tests pass and 0 errors.");
assert.equal(r.result, "fail");
});
test("the FAIL report explains that editing the body does not re-run the gate (push instead)", () => {
const { code, out } = run("All 20 tests pass and 0 errors."); // claim, no evidence
assert.equal(code, 1, "gate fails on a claim with no evidence");
assert.match(out, /Result: FAIL/);
assert.match(out, /does NOT re-run this gate/);
assert.match(out, /push a commit/i);
});
test("the hint does NOT appear when the gate passes", () => {
const { code, out } = run("Tests pass.\n\n## Evidence\n```\ntests 20 / pass 20 / fail 0\n```");
assert.equal(code, 0);
assert.match(out, /Result: PASS/);
assert.doesNotMatch(out, /does NOT re-run this gate/);
});

View File

@@ -0,0 +1,117 @@
import test from "node:test";
import assert from "node:assert/strict";
const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
const { REGISTRY: providerRegistry } = await import("../../open-sse/config/providerRegistry.ts");
const { unwrapClinepassEnvelope } = await import("../../open-sse/utils/clinepassEnvelope.ts");
const { filterClinepassModels } = await import("../../open-sse/services/clinepassModels.ts");
const { parseUpstreamError, buildErrorBody } = await import("../../open-sse/utils/error.ts");
// ── Provider metadata (Zod-validated APIKEY catalog) ─────────────────────────
test("ClinePass is registered as an API-key provider with the canonical identity", () => {
const cp = APIKEY_PROVIDERS.clinepass;
assert.ok(cp, "APIKEY_PROVIDERS.clinepass must be defined");
assert.equal(cp.id, "clinepass");
assert.equal(cp.alias, "clinepass");
assert.equal(cp.name, "ClinePass");
assert.equal(cp.website, "https://cline.bot");
assert.equal(
(cp as { notice?: { apiKeyUrl?: string } }).notice?.apiKeyUrl,
"https://app.cline.bot/settings/api-keys"
);
});
test("ClinePass registry entry uses OpenAI format with bearer apikey auth + Cline headers", () => {
const entry = providerRegistry.clinepass;
assert.ok(entry, "providerRegistry.clinepass must be defined");
assert.equal(entry.id, "clinepass");
assert.equal(entry.format, "openai");
assert.equal(entry.executor, "default");
assert.equal(entry.authType, "apikey");
assert.equal(entry.authHeader, "bearer");
assert.equal(entry.baseUrl, "https://api.cline.bot/api/v1/chat/completions");
assert.equal(entry.extraHeaders?.["HTTP-Referer"], "https://cline.bot");
assert.equal(entry.extraHeaders?.["X-Title"], "Cline");
});
test("ClinePass models are cline-pass/* and deepseek entries flag reasoning", () => {
const models = providerRegistry.clinepass.models;
const ids = models.map((m: { id: string }) => m.id);
assert.ok(ids.length >= 8, "expect a non-trivial seed list");
assert.equal(new Set(ids).size, ids.length, "model ids must be unique");
for (const id of ids) {
assert.ok(id.startsWith("cline-pass/"), `${id} must be in the cline-pass/ namespace`);
}
const deepseek = models.filter((m: { id: string }) => m.id.includes("deepseek"));
assert.ok(deepseek.length >= 2, "expect the two DeepSeek V4 entries");
for (const m of deepseek) {
assert.equal((m as { supportsReasoning?: boolean }).supportsReasoning, true);
}
});
// ── Envelope unwrap ──────────────────────────────────────────────────────────
test("unwrapClinepassEnvelope: success unwraps to data", () => {
const inner = { id: "chatcmpl-1", choices: [] };
const { body, error } = unwrapClinepassEnvelope({ success: true, data: inner }, "clinepass");
assert.equal(error, null);
assert.deepEqual(body, inner);
});
test("unwrapClinepassEnvelope: {success:false} yields an error", () => {
const { body, error } = unwrapClinepassEnvelope(
{ success: false, error: "empty response content", statusCode: 502 },
"clinepass"
);
assert.equal(body, null);
assert.ok(error);
assert.equal(error?.message, "empty response content");
assert.equal(error?.status, 502);
});
test("unwrapClinepassEnvelope: nested error.message extracted", () => {
const { error } = unwrapClinepassEnvelope(
{ success: false, error: { message: "quota exceeded" } },
"clinepass"
);
assert.equal(error?.message, "quota exceeded");
});
test("unwrapClinepassEnvelope: non-clinepass provider passes through untouched", () => {
const payload = { success: false, error: "boom" };
const { body, error } = unwrapClinepassEnvelope(payload, "openai");
assert.equal(error, null);
assert.deepEqual(body, payload);
});
test("unwrapClinepassEnvelope: non-object / array / no-success passthrough", () => {
assert.deepEqual(unwrapClinepassEnvelope("plain", "clinepass"), { body: "plain", error: null });
assert.deepEqual(unwrapClinepassEnvelope([1, 2], "clinepass"), { body: [1, 2], error: null });
const bare = { id: "x" };
assert.deepEqual(unwrapClinepassEnvelope(bare, "clinepass"), { body: bare, error: null });
});
// ── Model filter ─────────────────────────────────────────────────────────────
test("filterClinepassModels keeps only cline-pass/* ids", () => {
const out = filterClinepassModels([
{ id: "cline-pass/glm-5.2", name: "GLM" },
{ id: "openai/gpt-5.5" },
{ id: "cline-pass/deepseek-v4-pro" },
{ notId: true },
]);
assert.deepEqual(out, [
{ id: "cline-pass/glm-5.2", name: "GLM" },
{ id: "cline-pass/deepseek-v4-pro", name: "cline-pass/deepseek-v4-pro" },
]);
assert.deepEqual(filterClinepassModels("not-array"), []);
});
// ── Error sanitization (Rule #12 — no stack leak) ────────────────────────────
test("parseUpstreamError unwraps clinepass envelope error without leaking a stack", async () => {
const upstream = new Response(
JSON.stringify({ success: false, error: "upstream at /srv/x.js:1:1 failed" }),
{ status: 502, headers: { "content-type": "application/json" } }
);
const parsed = await parseUpstreamError(upstream, "clinepass");
const body = buildErrorBody(502, parsed.message) as { error: { message: string } };
assert.ok(!body.error.message.includes("at /"), "sanitized error must not include a stack frame");
});

View File

@@ -0,0 +1,77 @@
import test from "node:test";
import assert from "node:assert/strict";
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
// DefaultExecutor.ensureThinkingBudget — clinepass-gated max_tokens floor for
// reasoning models (prevents empty content when the budget is undersized).
test("bumps undersized max_tokens to 4096 for a clinepass reasoning model", () => {
const executor = new DefaultExecutor("clinepass");
const body = {
model: "cline-pass/deepseek-v4-pro",
reasoning_effort: "high",
max_tokens: 512,
} as Record<string, unknown>;
executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-pro");
assert.equal(body.max_tokens, 4096);
});
test("sets max_tokens floor when absent for a reasoning model", () => {
const executor = new DefaultExecutor("clinepass");
const body = {
model: "cline-pass/deepseek-v4-flash",
reasoning_effort: "medium",
} as Record<string, unknown>;
executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-flash");
assert.equal(body.max_tokens, 4096);
});
test("leaves an already-sufficient budget untouched", () => {
const executor = new DefaultExecutor("clinepass");
const body = {
model: "cline-pass/deepseek-v4-pro",
reasoning_effort: "high",
max_tokens: 8000,
} as Record<string, unknown>;
executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-pro");
assert.equal(body.max_tokens, 8000);
});
test("no-op when reasoning is disabled", () => {
const executor = new DefaultExecutor("clinepass");
const body = {
model: "cline-pass/deepseek-v4-pro",
max_tokens: 100,
} as Record<string, unknown>;
executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-pro");
assert.equal(body.max_tokens, 100);
});
test("no-op for a non-reasoning clinepass model", () => {
const executor = new DefaultExecutor("clinepass");
const body = {
model: "cline-pass/glm-5.2",
reasoning_effort: "high",
max_tokens: 100,
} as Record<string, unknown>;
executor.ensureThinkingBudget(body, "cline-pass/glm-5.2");
assert.equal(body.max_tokens, 100);
});
test("no-op for a non-clinepass provider (gate)", () => {
const executor = new DefaultExecutor("openrouter");
const body = {
model: "cline-pass/deepseek-v4-pro",
reasoning_effort: "high",
max_tokens: 100,
} as Record<string, unknown>;
executor.ensureThinkingBudget(body, "cline-pass/deepseek-v4-pro");
assert.equal(body.max_tokens, 100);
});

View File

@@ -0,0 +1,181 @@
// #5903: Codex session affinity must win over a per-request reset-aware
// re-scoring. The reset-aware combo strategy (open-sse/services/combo/quotaStrategies.ts)
// recomputes its "winner" connection on every request and hands it to
// getProviderCredentials as forcedConnectionId (src/sse/handlers/chat.ts).
// Before the fix, forcedConnectionId narrowed the connection pool BEFORE
// session affinity was consulted, so a fresh quota-scoring winner silently
// evicted the existing pin (deleteSessionAccountAffinity) on every request —
// breaking "same session -> reuse pinned account".
//
// This test drives auth.getProviderCredentials directly (the same call shape
// chat.ts uses: sessionKey + forcedConnectionId together) to reproduce the
// bug without needing the full combo/quota-scoring machinery.
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-codex-affinity-5903-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "codex-affinity-5903-test-secret";
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const affinityDb = await import("../../src/lib/db/sessionAccountAffinity.ts");
const auth = await import("../../src/sse/services/auth.ts");
async function resetStorage() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
async function seedConnection(provider: string, overrides: any = {}) {
return providersDb.createProviderConnection({
provider,
authType: overrides.authType || "oauth",
name: overrides.name || `${provider}-${Math.random().toString(16).slice(2, 8)}`,
accessToken: overrides.accessToken || `at-${Math.random().toString(16).slice(2, 10)}`,
refreshToken: overrides.refreshToken,
isActive: overrides.isActive ?? true,
testStatus: overrides.testStatus || "active",
priority: overrides.priority,
providerSpecificData: overrides.providerSpecificData || {},
});
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("codex session affinity wins over a per-request reset-aware forcedConnectionId (#5903)", async () => {
await settingsDb.updateSettings({
fallbackStrategy: "reset-aware",
codexSessionAffinityTtlMs: 60_000,
});
const connectionA = await seedConnection("codex", { name: "codex-reset-aware-a" });
const connectionB = await seedConnection("codex", { name: "codex-reset-aware-b" });
// Request 1: reset-aware quota scoring picks A as the winner for session S.
const request1 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", {
sessionKey: "session-S",
forcedConnectionId: connectionA.id,
});
assert.equal(request1?.connectionId, connectionA.id, "request 1 should pin to the scored winner A");
assert.equal(
affinityDb.getSessionAccountAffinity("session-S", "codex", 60_000)?.connectionId,
connectionA.id,
"affinity row must be created for session-S pointing at A"
);
// Request 2: quota state shifted and reset-aware now scores B higher for
// the SAME session. Without the fix, forcedConnectionId=B narrows the pool
// to just B before affinity is checked, evicting the A pin and re-pinning
// to B. With the fix, the existing active pin (A) must win.
const request2 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", {
sessionKey: "session-S",
forcedConnectionId: connectionB.id,
});
assert.equal(
request2?.connectionId,
connectionA.id,
"request 2 must still use the pinned connection A, not the freshly re-scored B"
);
assert.equal(
affinityDb.getSessionAccountAffinity("session-S", "codex", 60_000)?.connectionId,
connectionA.id,
"affinity row for session-S must remain pinned to A after re-scoring"
);
// A brand-new session (S2) has no existing pin, so the freshly re-scored
// winner (B) must be honored and a NEW pin created for S2.
const request3 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", {
sessionKey: "session-S2",
forcedConnectionId: connectionB.id,
});
assert.equal(request3?.connectionId, connectionB.id, "a new session must honor the fresh re-scored pick");
assert.equal(
affinityDb.getSessionAccountAffinity("session-S2", "codex", 60_000)?.connectionId,
connectionB.id,
"a new affinity row for session-S2 must be created pointing at B"
);
// Session S must remain unaffected by S2's independent pin.
assert.equal(
affinityDb.getSessionAccountAffinity("session-S", "codex", 60_000)?.connectionId,
connectionA.id,
"session-S pin must stay isolated from session-S2"
);
});
test("reset-aware forcedConnectionId is honored when the pinned connection becomes ineligible (#5903)", async () => {
await settingsDb.updateSettings({
fallbackStrategy: "reset-aware",
codexSessionAffinityTtlMs: 60_000,
});
const connectionA = await seedConnection("codex", { name: "codex-reset-aware-ineligible-a" });
const connectionB = await seedConnection("codex", { name: "codex-reset-aware-ineligible-b" });
const request1 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", {
sessionKey: "session-failover",
forcedConnectionId: connectionA.id,
});
assert.equal(request1?.connectionId, connectionA.id);
// A becomes rate-limited (e.g. 429 handled by markAccountUnavailable in
// production). Reset-aware re-scores and now forces B. The pin (A) is no
// longer eligible, so the freshly forced B must be used instead of
// failing the whole request.
await providersDb.updateProviderConnection(connectionA.id, {
rateLimitedUntil: new Date(Date.now() + 60_000).toISOString(),
});
const request2 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", {
sessionKey: "session-failover",
forcedConnectionId: connectionB.id,
});
assert.equal(
request2?.connectionId,
connectionB.id,
"an ineligible pin must fall through to the freshly forced connection"
);
});
test("no session affinity configured: reset-aware forcedConnectionId applies exactly as before (#5903)", async () => {
await settingsDb.updateSettings({
fallbackStrategy: "reset-aware",
codexSessionAffinityTtlMs: 0,
});
const connectionA = await seedConnection("codex", { name: "codex-no-affinity-a" });
const connectionB = await seedConnection("codex", { name: "codex-no-affinity-b" });
const request1 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", {
sessionKey: "session-no-ttl",
forcedConnectionId: connectionA.id,
});
assert.equal(request1?.connectionId, connectionA.id);
const request2 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", {
sessionKey: "session-no-ttl",
forcedConnectionId: connectionB.id,
});
assert.equal(
request2?.connectionId,
connectionB.id,
"with affinity disabled (ttl=0) each request must honor the fresh forcedConnectionId"
);
});

View File

@@ -0,0 +1,183 @@
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";
/**
* #5923 (Finding #4) — the quota-exhaustion preflight cutoff only ran for
* strategy === "auto" (buildAutoCandidates / routableCandidates in combo.ts).
* Priority/weighted/etc. strategies funneled through the shared executeTarget
* per-target loop, which only checked the provider circuit breaker + model
* lockout — never a per-(provider, connection) quota-exhaustion cutoff. A 0%-
* remaining connection stayed eligible as the lead leg until it reactively
* 429'd.
*
* Regression guard: with the quota-exhaustion opt-in enabled
* (`resilienceSettings.quotaPreflight.enabled = true`), a "priority" combo
* whose first-listed connection is at 0% remaining must skip straight to the
* sibling connection of the SAME provider — never dispatching to the
* exhausted connection. This must stay strictly per-connection: it must NOT
* touch the provider circuit breaker (both connections belong to the same
* provider, and the healthy one must remain fully eligible).
*/
const TEST_DATA_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-quota-cutoff-priority-5923-")
);
process.env.DATA_DIR = TEST_DATA_DIR;
const dbCore = await import("../../src/lib/db/core.ts");
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
const { registerQuotaFetcher } = await import("../../open-sse/services/quotaPreflight.ts");
const { getCircuitBreaker } = await import("../../src/shared/utils/circuitBreaker.ts");
test.after(() => {
dbCore.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
function makeLog() {
return {
info() {},
warn() {},
debug() {},
error() {},
};
}
function okResponse(model: string) {
return Response.json({ choices: [{ message: { role: "assistant", content: model } }] });
}
const PROVIDER = "openai";
const EXHAUSTED_CONNECTION_ID = "conn-exhausted-5923";
const HEALTHY_CONNECTION_ID = "conn-healthy-5923";
test("#5923 priority combo skips a 0%-remaining lead connection but keeps the sibling connection eligible", async () => {
registerQuotaFetcher(PROVIDER, async (connectionId: string) => {
if (connectionId === EXHAUSTED_CONNECTION_ID) {
return { used: 100, total: 100, percentUsed: 1 };
}
return { used: 5, total: 100, percentUsed: 0.05 };
});
const combo = {
name: `priority-quota-cutoff-5923-${Date.now()}`,
strategy: "priority",
models: [
{
kind: "model",
provider: PROVIDER,
providerId: PROVIDER,
model: "gpt-4o-mini",
connectionId: EXHAUSTED_CONNECTION_ID,
id: "step-a",
},
{
kind: "model",
provider: PROVIDER,
providerId: PROVIDER,
model: "gpt-4o-mini",
connectionId: HEALTHY_CONNECTION_ID,
id: "step-b",
},
],
};
const calls: Array<string | null> = [];
const response = await handleComboChat({
body: { model: combo.name, messages: [{ role: "user", content: "hi" }] },
combo,
allCombos: [combo],
isModelAvailable: undefined,
relayOptions: undefined,
signal: undefined,
settings: {
resilienceSettings: {
quotaPreflight: {
enabled: true,
defaultThresholdPercent: 2,
warnThresholdPercent: 20,
},
},
},
log: makeLog(),
handleSingleModel: async (
_body: unknown,
modelStr: string,
target?: { connectionId?: string | null }
) => {
calls.push(target?.connectionId ?? null);
return okResponse(modelStr);
},
} as Parameters<typeof handleComboChat>[0]);
assert.equal(response.status, 200);
assert.ok(calls.length > 0, "expected at least one dispatched target");
assert.equal(
calls[0],
HEALTHY_CONNECTION_ID,
"the 0%-remaining lead connection must be skipped; the sibling connection must be dispatched instead"
);
assert.ok(
!calls.includes(EXHAUSTED_CONNECTION_ID),
"the exhausted connection must never be dispatched to"
);
// Strictly per-connection — the provider circuit breaker must stay CLOSED.
// Only one connection was skipped; the provider itself never failed.
assert.equal(
getCircuitBreaker(PROVIDER).getStatus().state,
"CLOSED",
"quota-exhaustion cutoff must never trip the whole-provider circuit breaker"
);
});
test("#5923 priority combo does NOT skip a 0%-remaining connection when the cutoff setting is disabled (default)", async () => {
const provider = "openai";
const exhaustedConnectionId = "conn-exhausted-disabled-5923";
registerQuotaFetcher(provider, async () => ({ used: 100, total: 100, percentUsed: 1 }));
const combo = {
name: `priority-quota-cutoff-disabled-5923-${Date.now()}`,
strategy: "priority",
models: [
{
kind: "model",
provider,
providerId: provider,
model: "gpt-4o-mini",
connectionId: exhaustedConnectionId,
id: "step-a",
},
],
};
const calls: Array<string | null> = [];
const response = await handleComboChat({
body: { model: combo.name, messages: [{ role: "user", content: "hi" }] },
combo,
allCombos: [combo],
isModelAvailable: undefined,
relayOptions: undefined,
signal: undefined,
// No resilienceSettings override → quotaPreflight.enabled defaults to false (opt-in).
settings: {},
log: makeLog(),
handleSingleModel: async (
_body: unknown,
modelStr: string,
target?: { connectionId?: string | null }
) => {
calls.push(target?.connectionId ?? null);
return okResponse(modelStr);
},
} as Parameters<typeof handleComboChat>[0]);
assert.equal(response.status, 200);
assert.deepEqual(
calls,
[exhaustedConnectionId],
"with the cutoff setting OFF (default), the exhausted connection must still be dispatched to (unchanged auto-off behavior)"
);
});

View File

@@ -0,0 +1,46 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseSSELine, stripAnsiCodes } from "../../open-sse/utils/streamHelpers.ts";
test("parseSSELine resolves an ANSI/VT100-prefixed data: frame (gemini-cli redraw)", () => {
// gemini-cli prefixes SSE frames with cursor-redraw escapes (\x1b[2K clears the
// line, \x1b[1A moves the cursor up). 0x1b is not whitespace, so before the fix
// trimStart().startsWith("data:") failed and the frame was silently dropped (#2273).
const line = `\x1b[2K\x1b[1Adata: ${JSON.stringify({
choices: [{ delta: { content: "hi" } }],
})}`;
const r = parseSSELine(line);
assert.ok(r, "expected a parsed payload, got null (frame was dropped)");
assert.equal(r?.choices?.[0]?.delta?.content, "hi");
});
test("parseSSELine returns null for a pure-ANSI line (nothing after stripping)", () => {
assert.equal(parseSSELine("\x1b[2K\x1b[1A"), null);
});
test("stripAnsiCodes strips CSI/SGR/OSC/C0 but preserves \\t \\n \\r", () => {
// CSI cursor moves + SGR color codes
assert.equal(stripAnsiCodes("\x1b[2K\x1b[1Ahello"), "hello");
assert.equal(stripAnsiCodes("\x1b[31mred\x1b[0m"), "red");
// OSC sequence terminated by BEL (\x07)
assert.equal(stripAnsiCodes("\x1b]0;title\x07text"), "text");
// OSC sequence terminated by ST (\x1b\\)
assert.equal(stripAnsiCodes("\x1b]8;;https://x\x1b\\link"), "link");
// stray C0 control byte
assert.equal(stripAnsiCodes("a\x00b"), "ab");
// whitespace preserved
assert.equal(stripAnsiCodes("a\tb\nc\rd"), "a\tb\nc\rd");
});
test("stripAnsiCodes passes null/undefined through unchanged", () => {
assert.equal(stripAnsiCodes(null), null);
assert.equal(stripAnsiCodes(undefined), undefined);
});
test("stripAnsiCodes runs in linear time on adversarial input (ReDoS guard)", () => {
const hostile = "\x1b[" + "0;".repeat(50000) + "m";
const start = Date.now();
stripAnsiCodes(hostile);
assert.ok(Date.now() - start < 1000, "stripAnsiCodes should not backtrack catastrophically");
});

View File

@@ -0,0 +1,110 @@
import test from "node:test";
import assert from "node:assert/strict";
const mod = await import("../../scripts/release/gen-contributors.mjs");
const {
extractVersionSection,
parseContributors,
renderContributors,
injectContributors,
NOISE_HANDLES,
} = mod;
const FIXTURE = `# Changelog
## [Unreleased]
---
## [3.9.0] — 2026-08-01
### ✨ New Features
- **feat(a):** thing one. ([#100](https://github.com/x/y/pull/100) — thanks @alice)
- **feat(b):** uses \`@toon-format/toon\` and \`@dnd-kit\`. ([#101](https://github.com/x/y/pull/101) — thanks @bob)
### 🔧 Bug Fixes
- **fix(c):** direct commit fix. (thanks @carol)
- **fix(d):** extracted. Extracted from [#102](https://github.com/x/y/pull/102) by [@dave](https://github.com/dave).
### 📝 Maintenance
- **refactor(rollup):** god-file split ([#200](https://github.com/x/y/pull/200), [#201](https://github.com/x/y/pull/201) — thanks @erin); editorconfig ([#202](https://github.com/x/y/pull/202) — thanks @frank). — thanks @diegosouzapw
---
## [3.8.99] — 2026-07-31
### 🔧 Bug Fixes
- **fix(z):** other version, must not leak. ([#999](https://github.com/x/y/pull/999) — thanks @zoe)
---
`;
test("extractVersionSection returns only the target version body (not the next section)", () => {
const sec = extractVersionSection(FIXTURE, "3.9.0");
assert.ok(sec.includes("thing one"), "includes 3.9.0 content");
assert.ok(!sec.includes("must not leak"), "excludes 3.8.99 content");
assert.ok(!sec.includes("#999"), "does not bleed into next version");
});
test("parseContributors credits per parenthetical group, not a flat scan", () => {
const agg = parseContributors(extractVersionSection(FIXTURE, "3.9.0"));
// rollup: erin gets 200+201, frank gets 202 — NOT both getting all three
assert.deepEqual(
[...agg.get("erin")].sort((a, b) => a - b),
[200, 201]
);
assert.deepEqual([...agg.get("frank")], [202]);
// simple bullets
assert.deepEqual([...agg.get("alice")], [100]);
// direct-commit credit with no PR ref
assert.ok(agg.has("carol") && agg.get("carol").size === 0);
// "Extracted from #N by @X"
assert.deepEqual([...agg.get("dave")], [102]);
});
test("noise handles and the maintainer are excluded from the contributor map", () => {
const agg = parseContributors(extractVersionSection(FIXTURE, "3.9.0"));
assert.ok(!agg.has("toon-format"), "package scope is not a contributor");
assert.ok(!agg.has("dnd-kit"), "package scope is not a contributor");
assert.ok(!agg.has("diegosouzapw"), "maintainer is rendered separately, not in the map");
assert.ok(NOISE_HANDLES.has("toon-format"));
});
test("renderContributors emits an alphabetical table with maintainer last", () => {
const agg = parseContributors(extractVersionSection(FIXTURE, "3.9.0"));
const table = renderContributors("3.9.0", agg);
assert.ok(table.startsWith("### 🙌 Contributors"));
const rows = table.split("\n").filter((l) => l.startsWith("| [@"));
const handles = rows.map((r) => r.match(/@([A-Za-z0-9_-]+)/)[1]);
assert.equal(handles[handles.length - 1], "diegosouzapw", "maintainer is last");
const external = handles.slice(0, -1);
assert.deepEqual(
external,
[...external].sort((a, b) => a.localeCompare(b)),
"external sorted"
);
assert.ok(table.includes("| [@carol](https://github.com/carol) | direct commit / report |"));
});
test("injectContributors inserts before the closing --- and is idempotent", () => {
const once = injectContributors(
FIXTURE,
"3.9.0",
renderContributors("3.9.0", parseContributors(extractVersionSection(FIXTURE, "3.9.0")))
);
assert.ok(once.includes("### 🙌 Contributors"), "section injected");
// 3.8.99 untouched
assert.ok(once.includes("must not leak"));
// idempotent: injecting again does not duplicate
const twice = injectContributors(
once,
"3.9.0",
renderContributors("3.9.0", parseContributors(extractVersionSection(once, "3.9.0")))
);
const count = (twice.match(/### 🙌 Contributors/g) || []).length;
assert.equal(count, 1, "no duplicate Contributors section on re-run");
});

View File

@@ -0,0 +1,63 @@
import test from "node:test";
import assert from "node:assert/strict";
const mod = await import("../../scripts/release/list-uncovered-commits.mjs");
const { refsOf, typeOf, computeUncovered, changelogRefWindow } = mod;
test("refsOf extracts every #N from a subject", () => {
assert.deepEqual(refsOf("fix(x): thing (#5842) (#5901)"), [5842, 5901]);
assert.deepEqual(refsOf("chore: no refs here"), []);
});
test("typeOf reads the conventional-commit type", () => {
assert.equal(typeOf("feat(api): x"), "feat");
assert.equal(typeOf("fix: y"), "fix");
assert.equal(typeOf("refactor(db)!: z"), "refactor");
assert.equal(typeOf("Merge branch main"), "other");
});
test("computeUncovered: a commit is covered iff ANY of its refs is in the changelog window", () => {
const commits = [
{ hash: "a1", subject: "fix(x): covered by issue ref (#100)" }, // issue 100 in changelog
{ hash: "b2", subject: "feat(y): uncovered feature (#200)" }, // 200 not in changelog
{ hash: "c3", subject: "refactor(z): internal (#300)" }, // rollup type, uncovered
{ hash: "d4", subject: "chore: no ref at all" }, // no ref → uncovered, rollup
];
const refs = new Set([100]); // only #100 is documented
const { covered, uncovered } = computeUncovered(commits, refs);
assert.equal(covered, 1);
assert.equal(uncovered.length, 3);
const byHash = Object.fromEntries(uncovered.map((c) => [c.hash, c]));
assert.equal(byHash.b2.rollup, false, "feat is user-facing, not a rollup candidate");
assert.equal(byHash.c3.rollup, true, "refactor is a rollup candidate");
assert.equal(byHash.d4.rollup, true, "chore is a rollup candidate");
});
test("changelogRefWindow scans [Unreleased] + the version section but not older versions", () => {
const cl = `# Changelog
## [Unreleased]
- **fix:** something ([#10](u))
---
## [3.9.0] — x
### 🔧 Bug Fixes
- **fix(a):** landed ([#20](u))
---
## [3.8.99] — y
- **fix(old):** must not count ([#999](u))
---
`;
const refs = changelogRefWindow(cl, "3.9.0");
assert.ok(refs.has(10), "picks up [Unreleased] refs");
assert.ok(refs.has(20), "picks up the target version refs");
assert.ok(!refs.has(999), "does NOT bleed into the previous version");
});

View File

@@ -347,3 +347,76 @@ test("handleMcpStreamableHTTP keeps 400 for a missing session id (non-initialize
// only the *present-but-unknown* case changed to 404. This must NOT regress.
assert.equal(res.status, 400);
});
test("handleMcpStreamableHTTP auto-recovers when stale session id is sent with initialize", async () => {
mod.shutdownMcpHttp();
const initReq = new Request("http://localhost/api/mcp/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
id: 1,
params: {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "test-client", version: "1.0.0" },
},
}),
});
const firstRes = await mod.handleMcpStreamableHTTP(initReq);
const staleSessionId = firstRes.headers.get("mcp-session-id");
if (!staleSessionId) {
mod.shutdownMcpHttp();
return;
}
mod.shutdownMcpHttp();
assert.equal(mod.isMcpHttpActive(), false);
const reinitReq = new Request("http://localhost/api/mcp/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"mcp-session-id": staleSessionId,
},
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
id: 2,
params: {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "test-client", version: "1.0.0" },
},
}),
});
const recoveryRes = await mod.handleMcpStreamableHTTP(reinitReq);
assert.notEqual(
recoveryRes.status,
404,
"Stale session + initialize must NOT return 404 — server should auto-recover"
);
assert.ok(
recoveryRes.status >= 200 && recoveryRes.status < 300,
`Expected 2xx response on auto-recovery, got ${recoveryRes.status}`
);
const newSessionId = recoveryRes.headers.get("mcp-session-id");
assert.ok(newSessionId, "Server must issue a new mcp-session-id on auto-recovery");
assert.equal(
mod.isMcpHttpActive(),
true,
"Server must have an active session after auto-recovery"
);
mod.shutdownMcpHttp();
});

View File

@@ -0,0 +1,56 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
// Split-guard for the openai-responses request-translator extraction.
// Pure shared primitives live in `openai-responses/helpers.ts`; the chat->Responses
// direction (`openaiToOpenAIResponsesRequest`) lives in `openai-responses/toResponses.ts`.
// The host keeps `openaiResponsesToOpenAIRequest` + both register() calls and re-exports
// the moved function so external importers (tests) keep working unchanged.
const HERE = dirname(fileURLToPath(import.meta.url));
const REQ = join(HERE, "../../open-sse/translator/request");
const HOST = join(REQ, "openai-responses.ts");
const HELPERS = join(REQ, "openai-responses/helpers.ts");
const TO_RESPONSES = join(REQ, "openai-responses/toResponses.ts");
test("helpers leaf is pure (no host import) and exports the shared primitives", () => {
const src = readFileSync(HELPERS, "utf8");
assert.doesNotMatch(src, /from "\.\.\/openai-responses\.ts"/);
for (const sym of ["toRecord", "toString", "clampCallId", "normalizeVerbosity"]) {
assert.match(src, new RegExp(`export (function|const) ${sym}\\b`));
}
});
test("toResponses leaf hosts the chat->Responses direction and imports helpers, not the host", () => {
const src = readFileSync(TO_RESPONSES, "utf8");
assert.match(src, /export function openaiToOpenAIResponsesRequest\(/);
assert.match(src, /from "\.\/helpers\.ts"/);
assert.doesNotMatch(src, /from "\.\.\/openai-responses\.ts"/);
});
test("host re-exports the moved function and keeps both register() directions", () => {
const src = readFileSync(HOST, "utf8");
assert.match(
src,
/export \{ openaiToOpenAIResponsesRequest \} from "\.\/openai-responses\/toResponses\.ts"/
);
assert.match(src, /export function openaiResponsesToOpenAIRequest\(/);
assert.match(src, /register\(FORMATS\.OPENAI_RESPONSES, FORMATS\.OPENAI,/);
assert.match(src, /register\(FORMATS\.OPENAI, FORMATS\.OPENAI_RESPONSES,/);
});
test("both directions are callable via the host module", async () => {
const mod = await import("../../open-sse/translator/request/openai-responses.ts");
assert.equal(typeof mod.openaiResponsesToOpenAIRequest, "function");
assert.equal(typeof mod.openaiToOpenAIResponsesRequest, "function");
// chat->Responses basic shape: wraps into { input: [...], stream: true }.
const out = mod.openaiToOpenAIResponsesRequest(
"gpt-4",
{ messages: [{ role: "user", content: "hi" }] },
true,
null
) as Record<string, unknown>;
assert.ok(Array.isArray(out.input));
});

View File

@@ -1,14 +1,37 @@
/**
* TDD regression for #5312 (FIX D / RC-D): openai-to-claude reconstructed a Claude
* `thinking` block from signature-less `reasoning_content` and stamped it with the
* fabricated DEFAULT_THINKING_CLAUDE_SIGNATURE. Anthropic validates signatures and
* rejects the fake one with 400 "Invalid signature in thinking block" — and
* claudeHelper's latest-assistant guard preserves the block verbatim, so the fake
* signature leaks upstream.
* TDD regression for #5312 (FIX D / RC-D) and #5945.
*
* Fix: emit a signature-less `redacted_thinking` placeholder (matching what
* prepareClaudeRequest produces downstream). A REAL part.signature must always be
* preserved verbatim — never overwritten with the default.
* #5312 (FIX D / RC-D): openai-to-claude reconstructed a Claude `thinking` block from
* signature-less `reasoning_content` and stamped it with the fabricated
* DEFAULT_THINKING_CLAUDE_SIGNATURE. Anthropic validates signatures and rejects the
* fake one with 400 "Invalid signature in thinking block" — and claudeHelper's
* latest-assistant guard preserves the block verbatim, so the fake signature leaks
* upstream.
*
* Fix (#5312): when a precursor thinking block IS required by Anthropic's schema
* (assistant turn has tool_use AND the outbound request has extended thinking
* enabled), emit a signature-less `redacted_thinking` placeholder (matching what
* prepareClaudeRequest produces downstream) instead of a fabricated-signature
* `thinking` block. A REAL part.signature must always be preserved verbatim — never
* overwritten with the default.
*
* #5945 (over-correction of #5312): the original #5312 fix injected the
* redacted_thinking placeholder UNCONDITIONALLY whenever ANY assistant history
* message carried non-empty `reasoning_content` — regardless of whether the current
* outbound request has thinking enabled, and regardless of whether that assistant
* turn even contains a `tool_use` block (the only case Anthropic's schema actually
* requires a preceding thinking/redacted_thinking block). This fabricated a content
* block the client never sent; reported by dev-cj: Claude Sonnet 5 via the "Pi"
* harness detected the extra block and refused the turn as prompt injection.
*
* Fix (#5945): gate the injection on BOTH (a) the assistant turn containing a
* tool_use block and (b) the outbound request having extended thinking enabled.
* Otherwise `reasoning_content` is dropped silently — it carries no useful signal
* for a plain-text replay turn, and the client never asked for it to appear.
*
* These two fixes are not in tension: #5312 legitimately fixed a real Anthropic 400
* for the case the redacted_thinking block IS required; #5945 narrows the trigger to
* exactly that case instead of firing for every reasoning_content-bearing message.
*/
import test from "node:test";
import assert from "node:assert/strict";
@@ -20,7 +43,7 @@ const { DEFAULT_THINKING_CLAUDE_SIGNATURE } = await import(
"../../open-sse/config/defaultThinkingSignature.ts"
);
test("#5312 RC-D: signature-less reasoning_content yields no fabricated-signature thinking block", () => {
test("#5945: reasoning_content on a plain-text assistant turn (no tool_use, thinking not requested) yields NO redacted_thinking/thinking block", () => {
const result = openaiToClaudeRequest(
"claude-opus-4-8",
{
@@ -28,6 +51,83 @@ test("#5312 RC-D: signature-less reasoning_content yields no fabricated-signatur
{ role: "user", content: "hello" },
{ role: "assistant", reasoning_content: "thinking about it", content: "hi there" },
],
// no body.thinking / body.reasoning_effort — thinking is NOT enabled for this request.
},
false
);
const assistant = result.messages.find((m) => m.role === "assistant");
assert.ok(assistant, "expected assistant message");
// No fabricated block at all — reasoning_content must be dropped silently, exactly
// like OPENAI_INCOMPATIBLE_ECHO_FIELDS drops other echo-only fields.
assert.equal(
assistant.content.find((b) => b && (b.type === "thinking" || b.type === "redacted_thinking")),
undefined,
"must NOT fabricate a thinking/redacted_thinking block the client never sent"
);
assert.deepEqual(
assistant.content.map((b) => b.type),
["text"],
"assistant content should contain only the real text block"
);
});
test("#5945: reasoning_content + tool_use, but thinking NOT enabled on the outbound request, yields NO injection", () => {
const result = openaiToClaudeRequest(
"claude-opus-4-8",
{
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
reasoning_content: "thinking about it",
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "get_weather", arguments: "{}" },
},
],
},
],
// no body.thinking / body.reasoning_effort — thinking is NOT enabled.
},
false
);
const assistant = result.messages.find((m) => m.role === "assistant");
assert.ok(assistant, "expected assistant message");
assert.equal(
assistant.content.find((b) => b && (b.type === "thinking" || b.type === "redacted_thinking")),
undefined,
"must NOT inject a precursor thinking block when the request itself has thinking disabled"
);
assert.ok(
assistant.content.some((b) => b.type === "tool_use"),
"tool_use block must still be present"
);
});
test("#5312: reasoning_content + tool_use + thinking ENABLED still gets a signature-less redacted_thinking precursor (the legitimate #5312 400-fix case)", () => {
const result = openaiToClaudeRequest(
"claude-opus-4-8",
{
thinking: { type: "enabled", budget_tokens: 4096 },
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
reasoning_content: "thinking about it",
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "get_weather", arguments: "{}" },
},
],
},
],
},
false
);
@@ -35,24 +135,25 @@ test("#5312 RC-D: signature-less reasoning_content yields no fabricated-signatur
const assistant = result.messages.find((m) => m.role === "assistant");
assert.ok(assistant, "expected assistant message");
// No block may carry the fabricated default signature.
// No block may carry the fabricated default signature on a `thinking`-typed block.
const fake = assistant.content.find(
(b) => b && b.signature === DEFAULT_THINKING_CLAUDE_SIGNATURE
(b) => b && b.type === "thinking" && b.signature === DEFAULT_THINKING_CLAUDE_SIGNATURE
);
assert.equal(fake, undefined, "must NOT emit a thinking block with the fabricated signature");
assert.equal(fake, undefined, "must NOT emit a `thinking` block with the fabricated signature");
// No `thinking`-typed block at all from signature-less reasoning_content.
// It becomes a redacted_thinking placeholder (Anthropic accepts without sig check),
// and it must precede the tool_use block.
assert.equal(assistant.content[0].type, "redacted_thinking", "must be the precursor block");
assert.equal(assistant.content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
assert.equal(
assistant.content.find((b) => b && b.type === "thinking"),
assistant.content[0].signature,
undefined,
"signature-less reasoning_content must not produce a `thinking` block"
"redacted_thinking must not carry a signature"
);
assert.ok(
assistant.content.some((b) => b.type === "tool_use"),
"tool_use block must still be present"
);
// It becomes a redacted_thinking placeholder (Anthropic accepts without sig check).
const redacted = assistant.content.find((b) => b && b.type === "redacted_thinking");
assert.ok(redacted, "expected a redacted_thinking placeholder");
assert.equal(redacted.data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
assert.equal(redacted.signature, undefined, "redacted_thinking must not carry a signature");
});
test("#5312 RC-D: a REAL thinking signature is preserved verbatim", () => {

View File

@@ -0,0 +1,38 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
// Split-guard for the openai-to-claude thinking-budget extraction.
// `fitThinkingToMaxTokens` (+ its private helpers safeCapMaxOutputTokens / MIN_*)
// live in the pure leaf `openai-to-claude/thinkingBudget.ts`; the host re-exports
// the public symbol so external importers (tests) keep working unchanged.
const HERE = dirname(fileURLToPath(import.meta.url));
const REQ = join(HERE, "../../open-sse/translator/request");
const HOST = join(REQ, "openai-to-claude.ts");
const LEAF = join(REQ, "openai-to-claude/thinkingBudget.ts");
test("leaf hosts fitThinkingToMaxTokens and does not import the host", () => {
const leaf = readFileSync(LEAF, "utf8");
assert.match(leaf, /export function fitThinkingToMaxTokens\(/);
assert.match(leaf, /function safeCapMaxOutputTokens\(/);
assert.doesNotMatch(leaf, /from "\.\.\/openai-to-claude\.ts"/);
});
test("host re-exports fitThinkingToMaxTokens from the leaf", () => {
const host = readFileSync(HOST, "utf8");
assert.match(
host,
/export \{ fitThinkingToMaxTokens \} from "\.\/openai-to-claude\/thinkingBudget\.ts"/
);
});
test("re-exported fitThinkingToMaxTokens is callable via the host module and behaves", async () => {
const mod = await import("../../open-sse/translator/request/openai-to-claude.ts");
assert.equal(typeof mod.fitThinkingToMaxTokens, "function");
// No budgeted thinking → max_tokens floored to >= 1, thinking passed through.
const out = mod.fitThinkingToMaxTokens("gpt-4o-mini", 0, undefined);
assert.equal(out.thinking, undefined);
assert.ok(out.maxTokens >= 1);
});

View File

@@ -0,0 +1,167 @@
// Tool-call emulation for the Perplexity Web executor in STREAMING mode (#5927).
//
// perplexity-web.ts converts <tool>{...}</tool> text into real OpenAI tool_calls
// only for non-streaming requests (the `hasTools && !stream` gate). Streaming
// requests — the default for agentic coding clients — got the raw <tool> text
// as plain delta.content and never emitted a tool_calls SSE delta, so clients
// could not execute tools. These tests live in a dedicated file mirroring
// tests/unit/chatgpt-web-tools-5240.test.ts (the reference fix for chatgpt-web).
import test from "node:test";
import assert from "node:assert/strict";
const { PerplexityWebExecutor } = await import("../../open-sse/executors/perplexity-web.ts");
const { __setTlsFetchOverrideForTesting } = await import(
"../../open-sse/services/perplexityTlsClient.ts"
);
// ─── Helper: Build a mock SSE stream from Perplexity events ─────────────────
function mockPplxStream(events: unknown[]) {
const encoder = new TextEncoder();
const chunks: string[] = [];
for (const evt of events) {
chunks.push(`event: message\r\ndata: ${JSON.stringify(evt)}\r\n\r\n`);
}
chunks.push("event: end_of_stream\r\n\r\n");
const body = chunks.join("");
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(body));
controller.close();
},
});
}
function installMockFetch(streamEvents: unknown[]) {
__setTlsFetchOverrideForTesting(async () => {
return {
status: 200,
headers: new Headers({ "Content-Type": "text/event-stream" }),
text: null,
body: mockPplxStream(streamEvents),
};
});
return () => __setTlsFetchOverrideForTesting(null);
}
const WEATHER_TOOL = {
type: "function",
function: {
name: "write_file",
description: "Write a file to disk",
parameters: {
type: "object",
properties: { path: { type: "string" }, content: { type: "string" } },
required: ["path", "content"],
},
},
};
const TOOL_CALL_TEXT =
'<tool>{"name":"write_file","arguments":{"path":"a.ts","content":"x"}}</tool>';
function toolEvents(text: string) {
return [
{
backend_uuid: "tool-uuid-1",
blocks: [
{
intended_usage: "markdown",
markdown_block: { chunks: [text], progress: "DONE" },
},
],
status: "COMPLETED",
},
];
}
test("Tools stream: <tool> text becomes delta.tool_calls + finish_reason tool_calls, NOT raw <tool> content (#5927)", async () => {
const restore = installMockFetch(toolEvents(TOOL_CALL_TEXT));
try {
const executor = new PerplexityWebExecutor();
const result = await executor.execute({
model: "pplx-auto",
body: {
messages: [{ role: "user", content: "write a file" }],
tools: [WEATHER_TOOL],
stream: true,
},
stream: true,
credentials: { apiKey: "test-cookie" },
signal: AbortSignal.timeout(10000),
log: null,
} as any);
assert.equal(result.response.status, 200);
assert.equal(result.response.headers.get("Content-Type"), "text/event-stream");
const text = await result.response.text();
const chunks = text
.split("\n")
.filter((l) => l.startsWith("data: ") && !l.includes("[DONE]"))
.map((l) => JSON.parse(l.slice(6)));
// Must NOT leak raw <tool> text as plain content.
assert.ok(
chunks.every((c) => {
const content = c.choices?.[0]?.delta?.content;
return typeof content !== "string" || !content.includes("<tool>");
}),
"no chunk contains raw <tool> text in delta.content"
);
const toolChunk = chunks.find((c) => c.choices[0].delta && c.choices[0].delta.tool_calls);
assert.ok(toolChunk, "a chunk carries delta.tool_calls");
assert.equal(toolChunk.choices[0].finish_reason, "tool_calls");
const tc = toolChunk.choices[0].delta.tool_calls;
assert.ok(Array.isArray(tc) && tc.length === 1);
assert.equal(tc[0].type, "function");
assert.equal(tc[0].function.name, "write_file");
assert.equal(typeof tc[0].function.arguments, "string", "arguments is a JSON string");
assert.deepEqual(JSON.parse(tc[0].function.arguments), { path: "a.ts", content: "x" });
const lastLine = text.trim().split("\n").filter(Boolean).pop();
assert.equal(lastLine, "data: [DONE]");
} finally {
restore();
}
});
test("Tools regression: streaming request with NO tools still streams plain content unchanged (#5927)", async () => {
const restore = installMockFetch(toolEvents("Just plain text, no tools."));
try {
const executor = new PerplexityWebExecutor();
const result = await executor.execute({
model: "pplx-auto",
body: { messages: [{ role: "user", content: "hi" }], stream: true },
stream: true,
credentials: { apiKey: "test-cookie" },
signal: AbortSignal.timeout(10000),
log: null,
} as any);
assert.equal(result.response.status, 200);
const text = await result.response.text();
const chunks = text
.split("\n")
.filter((l) => l.startsWith("data: ") && !l.includes("[DONE]"))
.map((l) => JSON.parse(l.slice(6)));
let assembled = "";
for (const c of chunks) {
const content = c.choices?.[0]?.delta?.content;
if (content) assembled += content;
}
assert.equal(assembled, "Just plain text, no tools.");
assert.ok(
chunks.every((c) => !(c.choices[0].delta && c.choices[0].delta.tool_calls)),
"no tool_calls emitted without a tools array"
);
const finishChunk = chunks.find((c) => c.choices[0].finish_reason);
assert.equal(finishChunk.choices[0].finish_reason, "stop");
} finally {
restore();
}
});

View File

@@ -320,6 +320,130 @@ test("provider models route discovers SiliconFlow models from configured China b
]);
});
test("provider models route handles local hostnames named 'v1' correctly", async () => {
const connection = await seedConnection("openai-compatible-local-v1", {
apiKey: "sk-local",
providerSpecificData: {
baseUrl: "http://v1/chat/completions",
},
});
const seenUrls: string[] = [];
globalThis.fetch = async (url) => {
seenUrls.push(String(url));
return Response.json({
data: [{ id: "local-v1-model", name: "Local v1 Model" }],
});
};
const response = await callRoute(connection.id);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(body.source, "api");
assert.deepEqual(seenUrls, ["http://v1/v1/models"]);
});
test("provider models route correctly strips standard /v1 paths", async () => {
const connection = await seedConnection("openai-compatible-standard-v1", {
apiKey: "sk-standard",
providerSpecificData: {
baseUrl: "https://api.openai.com/v1",
},
});
const seenUrls: string[] = [];
globalThis.fetch = async (url) => {
seenUrls.push(String(url));
return Response.json({
data: [{ id: "standard-model", name: "Standard Model" }],
});
};
const response = await callRoute(connection.id);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(body.source, "api");
assert.deepEqual(seenUrls, ["https://api.openai.com/v1/models"]);
});
test("provider models route strips /v1 when it precedes /chat/completions (#5899 no double /v1)", async () => {
// Regression for #5899 (Api Airforce): a baseUrl of the form
// "https://api.airforce/v1/chat/completions" must probe ".../v1/models" — NOT
// ".../v1/v1/models". The old `else if` strip chain only removed
// "/chat/completions", leaving a trailing "/v1" that the endpoint builder then
// doubled, producing a 308 redirect that aborted discovery.
const connection = await seedConnection("openai-compatible-airforce-v1", {
apiKey: "sk-airforce",
providerSpecificData: {
baseUrl: "https://api.airforce/v1/chat/completions",
},
});
const seenUrls: string[] = [];
globalThis.fetch = async (url) => {
seenUrls.push(String(url));
return Response.json({
data: [{ id: "airforce-model", name: "Airforce Model" }],
});
};
const response = await callRoute(connection.id);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(body.source, "api");
// First probed endpoint must have a single /v1 — no ".../v1/v1/models".
assert.equal(seenUrls[0], "https://api.airforce/v1/models");
assert.ok(
!seenUrls.some((u) => u.includes("/v1/v1/")),
`no endpoint should contain a doubled /v1: ${JSON.stringify(seenUrls)}`
);
});
test("provider models route continues probing past a REDIRECT_BLOCKED endpoint (#5899)", async () => {
// Regression for #5899: a REDIRECT_BLOCKED error on one candidate endpoint must
// not abort the whole probe loop — discovery should fall through to the next
// endpoint instead of surfacing an empty catalog.
const connection = await seedConnection("openai-compatible-redirect-v1", {
apiKey: "sk-redirect",
providerSpecificData: {
baseUrl: "https://redirect.example",
},
});
const seenUrls: string[] = [];
globalThis.fetch = async (url) => {
const u = String(url);
seenUrls.push(u);
// First candidate ".../v1/models" answers with a real 308 redirect →
// safeOutboundFetch throws a SafeOutboundFetchError(REDIRECT_BLOCKED). The old
// code re-threw on it (status 503) and aborted the loop; the fix `continue`s.
if (u === "https://redirect.example/v1/models") {
return new Response(null, {
status: 308,
headers: { location: "https://redirect.example/models" },
});
}
return Response.json({
data: [{ id: "redirect-model", name: "Redirect Model" }],
});
};
const response = await callRoute(connection.id);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
// Without the REDIRECT_BLOCKED `continue`, discovery aborted and fell back to a
// non-api catalog. The fix lets it reach the next endpoint and return live models.
assert.equal(body.source, "api");
assert.ok(
seenUrls.length >= 2,
`expected the loop to continue past REDIRECT_BLOCKED: ${JSON.stringify(seenUrls)}`
);
});
test("provider models route returns static catalog entries for providers with hardcoded models", async () => {
const connection = await seedConnection("bailian-coding-plan", {
apiKey: "bailian-key",

View File

@@ -0,0 +1,86 @@
// Regression guard for #5907 — the provider-scoped chat/completions route
// (`/v1/providers/{provider}/chat/completions`) must NOT re-apply the strict
// `providerChatCompletionSchema`. Full body-format validation is delegated to
// handleChat; the route keeps only the minimal guards it needs to normalize the
// model prefix. This test locks that contract without mocking handleChat (the
// project's node:test runner does not enable --experimental-test-module-mocks,
// and the Stryker tap-runner rejects mock.module), by exercising the branches
// that return BEFORE delegation:
// - a loosely-valid body (no `messages`, which the removed strict schema would
// have 400'd) now reaches the model-prefix logic instead of a schema 400;
// - the minimal route-level guards still reject invalid JSON, non-object
// bodies, non-string models, and unknown providers.
import { test, after } from "node:test";
import assert from "node:assert/strict";
const { POST } =
await import("../../src/app/api/v1/providers/[provider]/chat/completions/route.ts");
// Importing the route transitively opens the SQLite handle (handleChat's graph).
// Release it so Node's native test runner does not hang on open handles.
after(async () => {
try {
const core = await import("../../src/lib/db/core.ts");
core.resetDbInstance();
} catch {
// best-effort cleanup — never fail the suite on teardown
}
});
function makeRequest(body: string) {
return new Request("http://localhost/v1/providers/openai/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
}
const params = (provider: string) => ({ params: Promise.resolve({ provider }) });
test("#5907 a loosely-valid body (no messages) is no longer rejected by the removed strict schema", async () => {
// `{ model: "anthropic/..." }` has NO `messages`. Under the old strict
// providerChatCompletionSchema this 400'd at the route before any prefix
// check. The relaxed route must instead run the model-prefix validation and
// return the *specific* cross-provider error — proving the schema is gone.
const res = await POST(
makeRequest(JSON.stringify({ model: "anthropic/claude-3-5-sonnet" })),
params("openai")
);
assert.equal(res.status, 400);
const body = await res.json();
assert.match(
body.error.message,
/does not belong to provider/i,
"expected the model-prefix check to run — a schema validation 400 would mean the strict schema is still applied"
);
});
test("#5907 rejects invalid JSON with 400", async () => {
const res = await POST(makeRequest("{not json"), params("openai"));
assert.equal(res.status, 400);
});
test("#5907 rejects a non-object body (array) with 400", async () => {
const res = await POST(makeRequest(JSON.stringify([1, 2, 3])), params("openai"));
assert.equal(res.status, 400);
const body = await res.json();
assert.match(body.error.message, /must be a JSON object/i);
});
test("#5907 rejects a non-string model with 400", async () => {
const res = await POST(makeRequest(JSON.stringify({ model: 123 })), params("openai"));
assert.equal(res.status, 400);
const body = await res.json();
assert.match(body.error.message, /model must be a string/i);
});
test("#5907 rejects an unknown provider with 400", async () => {
const res = await POST(makeRequest(JSON.stringify({ model: "gpt-4o" })), params("nope-xyz"));
assert.equal(res.status, 400);
});
test("#5907 route errors are sanitized (no stack trace leak in body)", async () => {
const res = await POST(makeRequest("{bad"), params("openai"));
const body = await res.json();
assert.ok(!JSON.stringify(body).includes("at /"), "error body must not leak a stack trace");
});

View File

@@ -242,6 +242,48 @@ test("embedding and rerank specialty validators surface auth failures for Voyage
assert.equal(jina.error, "Invalid API key");
});
test("v0-vercel specialty validator checks the Platform API chats endpoint", async () => {
globalThis.fetch = async (url, init = {}) => {
assert.equal(String(url), "https://api.v0.dev/v1/chats?limit=1");
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer v0-key");
return new Response(JSON.stringify({ object: "list", data: [] }), { status: 200 });
};
const result = await validateProviderApiKey({
provider: "v0-vercel",
apiKey: "v0-key",
providerSpecificData: {
baseUrl: "https://api.v0.dev/v1/chat/completions",
},
});
assert.deepEqual(result, {
valid: true,
error: null,
method: "v0_platform_chats_list",
});
});
test("v0-vercel specialty validator treats auth failures as invalid API key", async () => {
globalThis.fetch = async (url, init = {}) => {
assert.equal(String(url), "https://api.v0.dev/v1/chats?limit=1");
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer bad-v0-key");
return new Response(JSON.stringify({ error: { type: "unauthorized_error" } }), {
status: 401,
});
};
const result = await validateProviderApiKey({
provider: "v0-vercel",
apiKey: "bad-v0-key",
providerSpecificData: {
baseUrl: "https://api.v0.dev/v1",
},
});
assert.equal(result.error, "Invalid API key");
});
test("gitlab specialty validator accepts PAT auth on the direct access endpoint", async () => {
globalThis.fetch = async (url, init = {}) => {
assert.equal(String(url), "https://gitlab.com/api/v4/code_suggestions/direct_access");
@@ -2783,12 +2825,14 @@ test("huggingface validator accepts a token whoami-v2 recognizes", async () => {
});
test("huggingface validator treats 401/403 as an invalid token", async () => {
globalThis.fetch = async () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
const unauthorized = await validateProviderApiKey({ provider: "huggingface", apiKey: "hf_bad" });
assert.equal(unauthorized.valid, false);
assert.equal(unauthorized.error, "Invalid API key");
globalThis.fetch = async () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 });
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 });
const forbidden = await validateProviderApiKey({ provider: "huggingface", apiKey: "hf_bad" });
assert.equal(forbidden.valid, false);
assert.equal(forbidden.error, "Invalid API key");
@@ -2800,7 +2844,10 @@ test("huggingface validator does NOT mark a fine-grained token invalid on a non-
// non-OK status must surface as a transient error, never "Invalid API key".
globalThis.fetch = async () => new Response("upstream down", { status: 503 });
const result = await validateProviderApiKey({ provider: "huggingface", apiKey: "hf_finegrained" });
const result = await validateProviderApiKey({
provider: "huggingface",
apiKey: "hf_finegrained",
});
assert.equal(result.valid, false);
assert.notEqual(result.error, "Invalid API key");

View File

@@ -31,12 +31,12 @@ test("barrel still exports every catalog + key helpers", () => {
}
});
test("APIKEY_PROVIDERS merges the 6 family files into 158 entries (no loss / no dup)", async () => {
test("APIKEY_PROVIDERS merges the 6 family files into 159 entries (no loss / no dup)", async () => {
const keys = Object.keys((P as Record<string, object>).APIKEY_PROVIDERS);
assert.equal(keys.length, 158);
assert.equal(new Set(keys).size, 158, "duplicate keys after spread-merge");
assert.equal(keys.length, 159);
assert.equal(new Set(keys).size, 159, "duplicate keys after spread-merge");
// the merged object's entry-count equals the sum of the 6 semantic family files; families are a
// strict partition (every provider in exactly one), so the sum must be exactly 158.
// strict partition (every provider in exactly one), so the sum must be exactly 159.
const families: [string, string][] = [
["gateways", "APIKEY_PROVIDERS_GATEWAYS"],
["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"],
@@ -56,7 +56,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 158 entries (no loss / no
seen.add(k);
}
}
assert.equal(famTotal, 158, "families must partition all 158 providers");
assert.equal(famTotal, 159, "families must partition all 159 providers");
});
test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {

View File

@@ -0,0 +1,59 @@
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";
/**
* #5923 (Finding #5) — `is_exhausted` on `quota_snapshots` rows was written from
* the connection-wide aggregate (`entries.every(q => q.remainingPercentage <= 0)`
* in `isExhausted()`), not from the specific window being persisted.
*
* A connection with one window at 0% and another window at 50% never got its
* 0%-window row flagged `is_exhausted=1`, because the AND-across-all-windows
* aggregate was false (the 50% window kept it false). The reporter observed
* ~360 of 274k snapshot rows ever set `is_exhausted=1` in production.
*
* Regression guard: `setQuotaCache` must persist `is_exhausted` per-window
* (`remainingPercentage <= 0` for THAT window), independent of sibling windows
* on the same connection.
*/
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-quota-per-window-5923-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const coreDb = await import("../../src/lib/db/core.ts");
const quotaSnapshotsDb = await import("../../src/lib/db/quotaSnapshots.ts");
const quotaCache = await import("../../src/domain/quotaCache.ts");
test.after(() => {
coreDb.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("#5923 setQuotaCache writes is_exhausted per-window, not the connection-wide AND aggregate", () => {
const connectionId = "conn-per-window-5923";
quotaCache.setQuotaCache(connectionId, "anthropic", {
session: { remainingPercentage: 0, resetAt: null },
weekly: { remainingPercentage: 50, resetAt: null },
});
const snapshots = quotaSnapshotsDb.getLatestQuotaSnapshotsForConnection(connectionId);
const sessionRow = snapshots.find((s: any) => (s.windowKey ?? s.window_key) === "session");
const weeklyRow = snapshots.find((s: any) => (s.windowKey ?? s.window_key) === "weekly");
assert.ok(sessionRow, "expected a persisted row for the session window");
assert.ok(weeklyRow, "expected a persisted row for the weekly window");
assert.equal(
(sessionRow as any).isExhausted ?? (sessionRow as any).is_exhausted,
1,
"the 0%-remaining session window must be flagged is_exhausted=1"
);
assert.equal(
(weeklyRow as any).isExhausted ?? (weeklyRow as any).is_exhausted,
0,
"the 50%-remaining weekly window must NOT be flagged is_exhausted (sibling window is exhausted, but this one isn't)"
);
});

Some files were not shown because too many files have changed in this diff Show More