mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
Merge remote-tracking branch 'origin/release/v3.8.44' into fix/port-pr-2299-ansi-sse-strip
This commit is contained in:
@@ -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.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
69
open-sse/translator/request/openai-responses/helpers.ts
Normal file
69
open-sse/translator/request/openai-responses/helpers.ts
Normal 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;
|
||||
}
|
||||
334
open-sse/translator/request/openai-responses/toResponses.ts
Normal file
334
open-sse/translator/request/openai-responses/toResponses.ts
Normal 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;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
92
open-sse/translator/response/openai-responses/pureHelpers.ts
Normal file
92
open-sse/translator/response/openai-responses/pureHelpers.ts
Normal 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("");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
186
scripts/release/gen-contributors.mjs
Normal file
186
scripts/release/gen-contributors.mjs
Normal 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));
|
||||
}
|
||||
119
scripts/release/list-uncovered-commits.mjs
Normal file
119
scripts/release/list-uncovered-commits.mjs
Normal 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));
|
||||
}
|
||||
@@ -533,7 +533,9 @@ export async function GET(
|
||||
base = base.slice(0, -17);
|
||||
} else if (base.endsWith("/completions")) {
|
||||
base = base.slice(0, -12);
|
||||
} else if (base.endsWith("/v1")) {
|
||||
}
|
||||
|
||||
if (base.endsWith("/v1") && !base.endsWith("://v1")) {
|
||||
base = base.slice(0, -3);
|
||||
}
|
||||
|
||||
@@ -576,6 +578,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;
|
||||
|
||||
55
tests/unit/check-pr-evidence.test.ts
Normal file
55
tests/unit/check-pr-evidence.test.ts
Normal 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/);
|
||||
});
|
||||
110
tests/unit/gen-contributors.test.ts
Normal file
110
tests/unit/gen-contributors.test.ts
Normal 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");
|
||||
});
|
||||
63
tests/unit/list-uncovered-commits.test.ts
Normal file
63
tests/unit/list-uncovered-commits.test.ts
Normal 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");
|
||||
});
|
||||
56
tests/unit/openai-responses-request-split.test.ts
Normal file
56
tests/unit/openai-responses-request-split.test.ts
Normal 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));
|
||||
});
|
||||
38
tests/unit/openai-to-claude-thinking-budget-split.test.ts
Normal file
38
tests/unit/openai-to-claude-thinking-budget-split.test.ts
Normal 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);
|
||||
});
|
||||
167
tests/unit/perplexity-web-streaming-tools-5927.test.ts
Normal file
167
tests/unit/perplexity-web-streaming-tools-5927.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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 response/openai-responses pure-helper extraction.
|
||||
// The stateless helpers (normalizeToolName / stripEmptyOptionalToolArgs /
|
||||
// normalizeOutputIndex / normalizeUpstreamFailure / extractResponsesReasoningSummaryText)
|
||||
// live in the pure leaf `openai-responses/pureHelpers.ts` (no stream state, no host import).
|
||||
// The host imports them back and re-exports normalizeUpstreamFailure for external importers.
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const RESP = join(HERE, "../../open-sse/translator/response");
|
||||
const HOST = join(RESP, "openai-responses.ts");
|
||||
const LEAF = join(RESP, "openai-responses/pureHelpers.ts");
|
||||
|
||||
test("leaf hosts the pure helpers, has no stream state and no host import", () => {
|
||||
const src = readFileSync(LEAF, "utf8");
|
||||
for (const sym of [
|
||||
"normalizeToolName",
|
||||
"stripEmptyOptionalToolArgs",
|
||||
"normalizeOutputIndex",
|
||||
"normalizeUpstreamFailure",
|
||||
"extractResponsesReasoningSummaryText",
|
||||
]) {
|
||||
assert.match(src, new RegExp(`export function ${sym}\\b`));
|
||||
}
|
||||
assert.doesNotMatch(src, /from "\.\.\/openai-responses\.ts"/);
|
||||
// No stream-state parameter leaked into the pure leaf (ignore comments).
|
||||
const code = src
|
||||
.split("\n")
|
||||
.filter((l) => !l.trim().startsWith("//"))
|
||||
.join("\n");
|
||||
assert.doesNotMatch(code, /\bstate\b/);
|
||||
});
|
||||
|
||||
test("host imports helpers back and re-exports normalizeUpstreamFailure", () => {
|
||||
const src = readFileSync(HOST, "utf8");
|
||||
assert.match(src, /from "\.\/openai-responses\/pureHelpers\.ts"/);
|
||||
assert.match(
|
||||
src,
|
||||
/export \{ normalizeUpstreamFailure \} from "\.\/openai-responses\/pureHelpers\.ts"/
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeUpstreamFailure preserves upstream error semantics", async () => {
|
||||
const { normalizeUpstreamFailure } =
|
||||
await import("../../open-sse/translator/response/openai-responses/pureHelpers.ts");
|
||||
assert.equal(
|
||||
normalizeUpstreamFailure({ error: { code: "rate_limit_exceeded", message: "slow down" } })
|
||||
.status,
|
||||
429
|
||||
);
|
||||
assert.equal(
|
||||
normalizeUpstreamFailure({ error: { code: "context_length_exceeded", message: "too big" } })
|
||||
.status,
|
||||
400
|
||||
);
|
||||
assert.equal(normalizeUpstreamFailure({ message: "boom" }).status, 502);
|
||||
});
|
||||
@@ -121,3 +121,22 @@ test("classifyRunError: a kill WITHOUT a configured timeout is not misreported a
|
||||
assert.equal(r.code, 1);
|
||||
assert.doesNotMatch(r.out, /ceiling/);
|
||||
});
|
||||
|
||||
test("pre-flight wires the test-masking PR-context gate against origin/main (v3.8.43 gap fix)", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const src = fs.readFileSync(
|
||||
new URL("../../scripts/quality/validate-release-green.mjs", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
// The gate must run check:test-masking, pin the base to main, and be classified HARD —
|
||||
// it caught a real net-assert reduction that only surfaced on the release PR before.
|
||||
assert.match(src, /check:test-masking/, "test-masking gate must be wired into the pre-flight");
|
||||
assert.match(src, /GITHUB_BASE_REF:\s*"main"/, "test-masking must diff against origin/main");
|
||||
assert.match(
|
||||
src,
|
||||
/id:\s*"test-masking"[\s\S]*?kind:\s*"hard"/,
|
||||
"test-masking must be a HARD gate (non-allowlisted weakening blocks the release)"
|
||||
);
|
||||
// run() must honor a per-gate env override so GITHUB_BASE_REF actually reaches the child.
|
||||
assert.match(src, /\.\.\.\(opts\.env \|\| \{\}\)/, "run() must merge opts.env into the child env");
|
||||
});
|
||||
|
||||
@@ -679,8 +679,13 @@ test("Kimi Web: targets www.kimi.com (international)", async () => {
|
||||
credentials: { apiKey: "kimi-auth=eyJ.eyJzdWI.signature" },
|
||||
});
|
||||
assert.ok(result.response instanceof Response);
|
||||
assert.ok(result.url.includes("www.kimi.com"), `got ${result.url}`);
|
||||
assert.ok(!result.url.includes("moonshot.cn"));
|
||||
// Parse the URL and assert on the exact hostname rather than a substring
|
||||
// match — `includes("www.kimi.com")` would also accept a hostile host like
|
||||
// `www.kimi.com.evil.net` or `evil.net/?x=www.kimi.com` (CodeQL
|
||||
// js/incomplete-url-substring-sanitization).
|
||||
const host = new URL(result.url).hostname;
|
||||
assert.equal(host, "www.kimi.com", `got ${result.url}`);
|
||||
assert.notEqual(host, "www.moonshot.cn", `got ${result.url}`);
|
||||
} finally {
|
||||
restore.restore();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user