From 0db0437fe75015692c5a924158862ddf2be5481d Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 20 Jun 2026 07:36:35 -0300 Subject: [PATCH 01/38] chore(release): open v3.8.31 development cycle --- CHANGELOG.md | 8 +++++++- docs/i18n/ar/CHANGELOG.md | 6 ++++++ docs/i18n/az/CHANGELOG.md | 6 ++++++ docs/i18n/bg/CHANGELOG.md | 6 ++++++ docs/i18n/bn/CHANGELOG.md | 6 ++++++ docs/i18n/cs/CHANGELOG.md | 6 ++++++ docs/i18n/da/CHANGELOG.md | 6 ++++++ docs/i18n/de/CHANGELOG.md | 6 ++++++ docs/i18n/es/CHANGELOG.md | 6 ++++++ docs/i18n/fa/CHANGELOG.md | 6 ++++++ docs/i18n/fi/CHANGELOG.md | 6 ++++++ docs/i18n/fr/CHANGELOG.md | 6 ++++++ docs/i18n/gu/CHANGELOG.md | 6 ++++++ docs/i18n/he/CHANGELOG.md | 6 ++++++ docs/i18n/hi/CHANGELOG.md | 6 ++++++ docs/i18n/hu/CHANGELOG.md | 6 ++++++ docs/i18n/id/CHANGELOG.md | 6 ++++++ docs/i18n/in/CHANGELOG.md | 6 ++++++ docs/i18n/it/CHANGELOG.md | 6 ++++++ docs/i18n/ja/CHANGELOG.md | 6 ++++++ docs/i18n/ko/CHANGELOG.md | 6 ++++++ docs/i18n/mr/CHANGELOG.md | 6 ++++++ docs/i18n/ms/CHANGELOG.md | 6 ++++++ docs/i18n/nl/CHANGELOG.md | 6 ++++++ docs/i18n/no/CHANGELOG.md | 6 ++++++ docs/i18n/phi/CHANGELOG.md | 6 ++++++ docs/i18n/pl/CHANGELOG.md | 6 ++++++ docs/i18n/pt-BR/CHANGELOG.md | 6 ++++++ docs/i18n/pt/CHANGELOG.md | 6 ++++++ docs/i18n/ro/CHANGELOG.md | 6 ++++++ docs/i18n/ru/CHANGELOG.md | 6 ++++++ docs/i18n/sk/CHANGELOG.md | 6 ++++++ docs/i18n/sv/CHANGELOG.md | 6 ++++++ docs/i18n/sw/CHANGELOG.md | 6 ++++++ docs/i18n/ta/CHANGELOG.md | 6 ++++++ docs/i18n/te/CHANGELOG.md | 6 ++++++ docs/i18n/th/CHANGELOG.md | 6 ++++++ docs/i18n/tr/CHANGELOG.md | 6 ++++++ docs/i18n/uk-UA/CHANGELOG.md | 6 ++++++ docs/i18n/ur/CHANGELOG.md | 6 ++++++ docs/i18n/vi/CHANGELOG.md | 6 ++++++ docs/i18n/zh-CN/CHANGELOG.md | 6 ++++++ docs/reference/openapi.yaml | 2 +- electron/package-lock.json | 4 ++-- electron/package.json | 2 +- open-sse/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 48 files changed, 262 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76af7585c160..ad75f22224bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.30] — 2026-06-20 ### ✨ New Features @@ -42,7 +48,7 @@ - **fix(providers): Cloudflare Workers AI model discovery shows model names, not UUIDs** — importing a Cloudflare Workers AI key listed models with internal UUID identifiers (e.g. `429b9e8b-d99e-…`) instead of their usable slugs (`@cf/meta/llama-3.1-8b-instruct`). Cloudflare's `/ai/models/search` returns `{ id: "", name: "@cf/…" }`, and discovery was passing the raw objects through — so the UUID `id` became the callable model id. The `cloudflare-ai` discovery now maps each result's `name` → id, surfacing the real `@cf/…` model ids. ([#4259](https://github.com/diegosouzapw/OmniRoute/issues/4259) — thanks @FerLuisxd) - **fix(translator): clamp Responses API `call_id` to 64 characters** — the OpenAI Responses API rejects `call_id` values longer than 64 characters with a 400. Long upstream tool-call ids (some clients emit ids well over the limit) are now clamped deterministically on both the `function_call` item and its matching `function_call_output`, so the pair stays matched through the orphaned-output filter and the request is accepted. (thanks @anuragg-saxenaa, @ngapngap) - **fix(oauth): GitHub Copilot token refresh now sends the public client_id** — the `github` provider config never carried a `clientId`, so GitHub OAuth `refresh_token` exchanges either omitted `client_id` or sent the literal string `undefined` (and a bogus `client_secret=undefined`), which GitHub rejects — leaving a Copilot connection stuck once its short-lived token expired and the long-lived refresh path was needed. The provider now resolves its public device-flow `client_id` from the embedded public credential and omits `client_secret` entirely (GitHub's Copilot app is a public client with no secret). (thanks @baslr) -- **fix(translator): a tool property named `pattern` survives Gemini/Antigravity schema sanitization** — the Gemini schema sanitizer strips JSON-Schema constraint keywords Gemini rejects (`pattern`, `minLength`, …) at every nesting level, but it also deleted any tool **property** literally *named* one of those keywords. glob/grep tools declare a property called `pattern`, so on `ag/*` (Antigravity) backends that argument (and its `required` entry) was silently dropped, breaking the tools. Keyword stripping is now position-aware: it only removes constraint keywords at the schema-node level and never against the user-defined names inside a `properties` map. A genuine string-level `pattern` *constraint* is still stripped. (thanks @youthanh) +- **fix(translator): a tool property named `pattern` survives Gemini/Antigravity schema sanitization** — the Gemini schema sanitizer strips JSON-Schema constraint keywords Gemini rejects (`pattern`, `minLength`, …) at every nesting level, but it also deleted any tool **property** literally _named_ one of those keywords. glob/grep tools declare a property called `pattern`, so on `ag/*` (Antigravity) backends that argument (and its `required` entry) was silently dropped, breaking the tools. Keyword stripping is now position-aware: it only removes constraint keywords at the schema-node level and never against the user-defined names inside a `properties` map. A genuine string-level `pattern` _constraint_ is still stripped. (thanks @youthanh) - **fix(translator): MCP `namespace` tools flatten to individual functions on the Responses→Chat path** — when a Codex CLI client routes a Responses-API request to a non-Codex backend (e.g. `kr/claude-opus-4.7`), each MCP server is declared as a `namespace` tool (`{ type:"namespace", name, tools:[…] }`). The Responses→Chat translator had no `namespace` branch, so the whole group collapsed into a single empty-schema function named `mcp____` and every MCP call returned `unsupported call: mcp____`, breaking all MCP-based workflows (context7, codegraph, custom MCPs) for that combination. The translator now expands a namespace into one Chat function per sub-tool (preserving each sub-tool's name and parameters); an empty namespace yields no tools instead of a broken placeholder. The native Codex passthrough path was already correct. (thanks @V13t4nh) - **fix(cli): the active remote-context credential wins over an ambient `OMNIROUTE_API_KEY`** — when a remote context is selected, its scoped access token now takes precedence over an `OMNIROUTE_API_KEY` present in the environment, so the connected remote is targeted as expected. ([#4364](https://github.com/diegosouzapw/OmniRoute/pull/4364)) - **fix(cli): wire the `contexts` command into the CLI program** — the `omniroute contexts` command (list/switch saved remote contexts) was implemented but never registered, so it was unreachable; it is now wired into the CLI program. ([#4369](https://github.com/diegosouzapw/OmniRoute/pull/4369)) diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 9907f91bb3b0..bb8b5007c029 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 5c62db0ab822..365bc677036b 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 5c62db0ab822..365bc677036b 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 0510df08babe..a0118c1a86c8 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index d2af0546f6b0..eb8f82bdd802 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 1483d835d673..60b9e087872b 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 6860d238604a..57180245f556 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 6a60c3344a36..48ca07cbfdda 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index b2b4033ea69c..5b9c3c3a7889 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index 85ad82fd6b04..20e74d86293e 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index d55e8efa79d6..aa28038338d7 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index 6def692e174c..17d9c7cf6485 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index e6bbb24fc7fa..6e658d949fa0 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 86cced1324c0..e92798f55785 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 381ac584b4c9..8d3a0965bb3f 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index c916318d95be..7910f2fd0c4f 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 71ae655755e8..ce562cc6b7be 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index 8c6abbb898e0..cd4c517de56d 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 087f412ed963..6fd2c119e874 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index e5c44b0c86ef..182b780a2873 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 5ad9641aaea9..ef74e0330a86 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 7ff721712a28..37ea8db17400 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index 0cb713bbb443..683c5c5bd2ae 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index 3940a40d38a8..8e8ded86b140 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index 0389f27f6b2d..d683a9625bc0 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 5b86b8bb5d08..768267a65f4d 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index fb69dff78b75..1844ec9142e6 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 2ba7b6995846..1cb71ac1a8a2 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index 4f70d4133cf8..cf5c3812fb10 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 849b5d700a64..d8522e684fbb 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 705120fae844..542a0281e2fe 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 9a38010e3a69..22a88c5543f0 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 4d4c646da2b8..6752c81bc5a0 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index a4ded531845d..ee37d43095fc 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 6cda7131d3d3..22c5c67a29eb 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 4470761d5608..611cfd4f9b65 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index a1ace61b2457..6ac2cb9a4004 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index c7822fdd6d87..ea4d004b0882 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index a1b65e8f08ae..a7af47720dd3 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 8ce2ca5a9217..dae1a08ccb09 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 3845ab12dd53..e059bb3d65ed 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.31] — TBD + +_See English CHANGELOG for v3.8.31 details._ + +--- + ## [3.8.30] — TBD _See English CHANGELOG for v3.8.30 details._ diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index ee68c297fe5e..ec6e5924e702 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.30 + version: 3.8.31 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/electron/package-lock.json b/electron/package-lock.json index 787e1707a828..a529189b2de9 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.30", + "version": "3.8.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.30", + "version": "3.8.31", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/electron/package.json b/electron/package.json index f874d553a551..60893a86eb79 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.30", + "version": "3.8.31", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/package.json b/open-sse/package.json index 330b69e72abf..519787213a4c 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.30", + "version": "3.8.31", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 863a1b806678..a20d01714cf0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.30", + "version": "3.8.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.30", + "version": "3.8.31", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -28241,7 +28241,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.30" + "version": "3.8.31" } } } diff --git a/package.json b/package.json index 00bd5d2a8447..de3edc11e8ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.30", + "version": "3.8.31", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { From fe70c4c5adbf7ac2807a2caab3de3e08cbc695df Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:21:55 -0300 Subject: [PATCH 02/38] fix(mitm): exact host membership in MITM hosts test (CodeQL false positive) (#4386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getMitmToolHosts returns string[], so .includes(host) is Array.prototype.includes (exact membership). CodeQL's js/incomplete-url-substring-sanitization heuristic misreads it as a String.includes() URL-substring sanitization check and raises a HIGH alert. Switch to .some(h => h === host) — identical semantics, explicit intent, no flagged pattern. Surfaced post-v3.8.30 (#4325) once the test landed on main. Test-only change (no runtime behavior); the suite still passes and the CodeQL re-scan on merge clears the alert. --- CHANGELOG.md | 4 ++++ tests/unit/mitm-tool-hosts.test.ts | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad75f22224bd..fe198feac15a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ _In development — bullets added per PR; finalized at release._ +### 🔒 Security + +- **fix(mitm): exact host membership in the MITM hosts test (CodeQL false positive)** — `tests/unit/mitm-tool-hosts.test.ts` checked host membership with `Array.includes(host)`, which CodeQL's `js/incomplete-url-substring-sanitization` heuristic misreads as a `String.includes()` URL-substring sanitization test (HIGH false positive). Switched to `.some((h) => h === host)` — identical semantics, no flagged pattern. ([#4386](https://github.com/diegosouzapw/OmniRoute/pull/4386)) + --- ## [3.8.30] — 2026-06-20 diff --git a/tests/unit/mitm-tool-hosts.test.ts b/tests/unit/mitm-tool-hosts.test.ts index 9023cfabf7d6..84a22f17b61b 100644 --- a/tests/unit/mitm-tool-hosts.test.ts +++ b/tests/unit/mitm-tool-hosts.test.ts @@ -21,6 +21,10 @@ test("MITM_TOOL_HOSTS stays in sync with the canonical MITM target registry", () }); test("getMitmToolHosts returns the hosts for a known tool and [] for unknown ids", () => { - assert.ok(getMitmToolHosts("antigravity").includes("cloudcode-pa.googleapis.com")); + // Exact host membership in the returned string[] — `.some(h => h === host)` rather than + // `.includes(host)` so CodeQL's js/incomplete-url-substring-sanitization heuristic does not + // misread an Array.prototype.includes() membership check as a String.includes() URL-substring + // test. Same semantics (the array contains exactly this host), explicit about intent. + assert.ok(getMitmToolHosts("antigravity").some((h) => h === "cloudcode-pa.googleapis.com")); assert.deepEqual(getMitmToolHosts("does-not-exist"), []); }); From 08a679508302beef9fc2e3c654c01aeb2ac391ef Mon Sep 17 00:00:00 2001 From: Xiangzhe <32761048+xz-dev@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:51:08 +0800 Subject: [PATCH 03/38] fix(codex): request reasoning summaries (#4359) Adds reasoning.summary=auto + reasoning.encrypted_content include for Codex. Thanks @xz-dev. --- open-sse/executors/codex.ts | 25 +++++++++++++++++++++++++ tests/unit/executor-codex.test.ts | 18 ++++++++---------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 661f380f5f98..03c2769beb93 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -618,6 +618,9 @@ function clampEffort(model: string, requested: string): string { return requested; } +const CODEX_REASONING_ENCRYPTED_CONTENT_INCLUDE = "reasoning.encrypted_content"; +const CODEX_DEFAULT_REASONING_SUMMARY = "auto"; + function normalizeEffortValue(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const normalized = value.trim().toLowerCase(); @@ -625,6 +628,27 @@ function normalizeEffortValue(value: unknown): string | undefined { return normalized || undefined; } +function ensureCodexReasoningSummary(body: Record): void { + const reasoning = + body.reasoning && typeof body.reasoning === "object" && !Array.isArray(body.reasoning) + ? (body.reasoning as Record) + : null; + if (!reasoning || normalizeEffortValue(reasoning.effort) === "none") return; + + if (!("summary" in reasoning)) { + reasoning.summary = CODEX_DEFAULT_REASONING_SUMMARY; + } + + if (!Array.isArray(body.include)) { + body.include = [CODEX_REASONING_ENCRYPTED_CONTENT_INCLUDE]; + return; + } + + if (!body.include.includes(CODEX_REASONING_ENCRYPTED_CONTENT_INCLUDE)) { + body.include = [...body.include, CODEX_REASONING_ENCRYPTED_CONTENT_INCLUDE]; + } +} + function consumeResponsesStoreMarker(body: Record): unknown { const marker = body._omnirouteResponsesStore; delete body._omnirouteResponsesStore; @@ -1321,6 +1345,7 @@ export class CodexExecutor extends BaseExecutor { effort: clampEffort(cleanModel, rawEffort), }; } + ensureCodexReasoningSummary(body); delete body.reasoning_effort; // Remove unsupported token limit parameters BEFORE the passthrough return. diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index ee200316e7b0..673b2052713a 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -242,10 +242,10 @@ test("CodexExecutor.transformRequest injects default instructions, clamps reason requestEndpointPath: "/responses", }); - assert.equal(result.stream, true); - assert.equal(result.store, false); + assert.deepEqual([result.stream, result.store], [true, false]); assert.equal(result.instructions.length > 0, true); - assert.equal(result.reasoning.effort, "high"); + assert.deepEqual(result.reasoning, { effort: "high", summary: "auto" }); + assert.deepEqual(result.include, ["reasoning.encrypted_content"]); assert.equal(result.service_tier, "priority"); assert.equal(result.messages, undefined); assert.equal(result.prompt, undefined); @@ -736,7 +736,7 @@ test("CodexExecutor.transformRequest keeps explicit request values ahead of conn } ); - assert.equal(result.reasoning.effort, "none"); + assert.deepEqual([result.reasoning, result.include], [{ effort: "none" }, undefined]); assert.equal(result.service_tier, "standard"); }); @@ -806,7 +806,8 @@ test("CodexExecutor.transformRequest passes GPT 5.4 Mini xhigh reasoning through { model: "gpt-5.4-mini", input: [], - reasoning: { effort: "xhigh", summary: "auto" }, + reasoning: { effort: "xhigh", summary: "detailed" }, + include: ["code_interpreter_call.outputs"], }, true, { @@ -822,11 +823,8 @@ test("CodexExecutor.transformRequest passes GPT 5.4 Mini xhigh reasoning through const reasoning = getRecord(sanitized.reasoning); assert.equal(sanitized.model, "gpt-5.4-mini"); - // #3756: xhigh now passes through by default. gpt-5.4-mini has no - // supportsXHighEffort:false flag (and ships a gpt-5.4-mini-xhigh catalog - // variant), so the effort is preserved instead of downgraded to "high". - assert.equal(reasoning.effort, "xhigh"); - assert.equal(reasoning.summary, "auto"); + assert.deepEqual(reasoning, { effort: "xhigh", summary: "detailed" }); + assert.deepEqual(sanitized.include, ["code_interpreter_call.outputs", "reasoning.encrypted_content"]); assert.equal(sanitized.reasoning_effort, undefined); }); From 55eb1b5a1476d02b3830c6d12f96b5d7d869b4ca Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:52:22 -0300 Subject: [PATCH 04/38] fix(embeddings): inject NVIDIA NIM input_type for asymmetric embed models (#4341) NVIDIA NIM asymmetric embedding models (e.g. nvidia/nv-embedqa-e5-v5) reject requests without an `input_type` ("query" | "passage") with 400 "'input_type' parameter is required". The embedding registry now carries a model-level default param for the asymmetric NVIDIA model, and the embeddings handler injects a model's default params into the upstream body only when the client omitted them, leaving a client-supplied value untouched. Reported-by: hydraromania (https://github.com/decolua/9router/issues/1378) Co-authored-by: hydraromania <252583922+hydraromania@users.noreply.github.com> --- CHANGELOG.md | 3 + open-sse/config/embeddingRegistry.ts | 41 ++++++- open-sse/handlers/embeddings.ts | 14 +++ .../unit/embeddings-nvidia-input-type.test.ts | 108 ++++++++++++++++++ 4 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 tests/unit/embeddings-nvidia-input-type.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe198feac15a..1c250727c0cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ _In development — bullets added per PR; finalized at release._ +### 🐛 Fixed + +- **fix(embeddings): NVIDIA NIM asymmetric embedding models inject the required `input_type`** — NVIDIA NIM asymmetric embedders (e.g. `nvidia/nv-embedqa-e5-v5`) reject requests without an `input_type` parameter with `400 "'input_type' parameter is required"`, but OmniRoute only forwarded `input_type` when the client supplied it — so callers (and OpenAI-style SDKs that don't emit the field) got a hard failure. The embedding registry now carries a model-level default (`input_type: "query"`) for the asymmetric NVIDIA model, and the embeddings handler injects a model's default params into the upstream body **only** when the client didn't already send them — a client-supplied `input_type` (e.g. `"passage"`) is respected unchanged, and symmetric models that carry no default are unaffected. (thanks @hydraromania) ### 🔒 Security - **fix(mitm): exact host membership in the MITM hosts test (CodeQL false positive)** — `tests/unit/mitm-tool-hosts.test.ts` checked host membership with `Array.includes(host)`, which CodeQL's `js/incomplete-url-substring-sanitization` heuristic misreads as a `String.includes()` URL-substring sanitization test (HIGH false positive). Switched to `.some((h) => h === host)` — identical semantics, no flagged pattern. ([#4386](https://github.com/diegosouzapw/OmniRoute/pull/4386)) diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index 63ce9d30f7a2..3063427a21ab 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -8,12 +8,25 @@ * keyed by provider ID (e.g. "nebius", "openai"). */ +export interface EmbeddingModel { + id: string; + name: string; + dimensions?: number; + /** + * Model-level default request parameters injected into the upstream body when + * the client did not already supply them. Used for asymmetric embedding models + * that require a mandatory parameter — e.g. NVIDIA NIM `nv-embedqa-*` models + * reject requests without `input_type` ("query" | "passage"). See issue #1378. + */ + defaultParams?: Record; +} + export interface EmbeddingProvider { id: string; baseUrl: string; authType: string; authHeader: string; - models: { id: string; name: string; dimensions?: number }[]; + models: EmbeddingModel[]; } export interface EmbeddingProviderNodeRow { @@ -130,7 +143,17 @@ export const EMBEDDING_PROVIDERS: Record = { baseUrl: "https://integrate.api.nvidia.com/v1/embeddings", authType: "apikey", authHeader: "bearer", - models: [{ id: "nvidia/nv-embedqa-e5-v5", name: "NV EmbedQA E5 v5", dimensions: 1024 }], + // nv-embedqa-* are asymmetric models: NVIDIA NIM rejects requests without an + // `input_type` ("query" | "passage") with 400 "'input_type' parameter is + // required". Default to "query" when the client omits it (issue #1378). + models: [ + { + id: "nvidia/nv-embedqa-e5-v5", + name: "NV EmbedQA E5 v5", + dimensions: 1024, + defaultParams: { input_type: "query" }, + }, + ], }, // Issue #2298: Adding DeepInfra to the embedding registry so custom @@ -359,6 +382,20 @@ export function detectEmbeddingDimensionConflict(modelStrs: string[]): { return { conflict: distinct.length > 1, dimensions, distinct }; } +/** + * Resolve the model-level default request params for a given provider config and + * model id. Returns undefined when the model has no defaults (the common case), + * so callers only inject for models that actually carry one (e.g. NVIDIA NIM + * asymmetric embedders requiring `input_type`). See issue #1378. + */ +export function getEmbeddingModelDefaultParams( + providerConfig: EmbeddingProvider | null, + modelId: string | null +): Record | undefined { + if (!providerConfig || !modelId) return undefined; + return providerConfig.models.find((m) => m.id === modelId)?.defaultParams; +} + /** * Get all embedding models as a flat list */ diff --git a/open-sse/handlers/embeddings.ts b/open-sse/handlers/embeddings.ts index e3ea746064e1..99e06d2fc59c 100644 --- a/open-sse/handlers/embeddings.ts +++ b/open-sse/handlers/embeddings.ts @@ -15,6 +15,7 @@ import { getEmbeddingProvider, + getEmbeddingModelDefaultParams, parseEmbeddingModel, type EmbeddingProvider, } from "../config/embeddingRegistry.ts"; @@ -143,6 +144,19 @@ export async function handleEmbedding({ } } + // Inject model-level default params (e.g. NVIDIA NIM asymmetric models require + // `input_type`) only for keys the client did not already supply, so a + // client-sent value is never overwritten. Symmetric models carry no defaults + // and are unaffected. See issue #1378. + const defaultParams = getEmbeddingModelDefaultParams(providerConfig, model); + if (defaultParams) { + for (const [key, value] of Object.entries(defaultParams)) { + if (upstreamBody[key] === undefined) { + upstreamBody[key] = value; + } + } + } + // Build headers const headers: Record = { "Content-Type": "application/json", diff --git a/tests/unit/embeddings-nvidia-input-type.test.ts b/tests/unit/embeddings-nvidia-input-type.test.ts new file mode 100644 index 000000000000..2d5cf413dd06 --- /dev/null +++ b/tests/unit/embeddings-nvidia-input-type.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-embeddings-nvidia-")); + +const { handleEmbedding } = await import("../../open-sse/handlers/embeddings.ts"); + +// Issue #1378: NVIDIA NIM asymmetric embedding models (e.g. nvidia/nv-embedqa-e5-v5) +// require an `input_type` parameter ("query" | "passage"); without it the upstream +// returns 400 "'input_type' parameter is required". OmniRoute must inject the +// registered model-level default when the client omits input_type, and must respect +// a client-supplied input_type when present. + +function captureFetch(captured: { body?: Record }) { + return async (url: unknown, options: { headers?: unknown; body?: unknown } = {}) => { + captured.body = JSON.parse(String(options.body || "{}")); + return new Response( + JSON.stringify({ + data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }], + usage: { prompt_tokens: 4, total_tokens: 4 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; +} + +test("handleEmbedding injects NVIDIA asymmetric default input_type when client omits it", async () => { + const originalFetch = globalThis.fetch; + const captured: { body?: Record } = {}; + globalThis.fetch = captureFetch(captured) as typeof fetch; + + try { + const result = await handleEmbedding({ + body: { + model: "nvidia/nvidia/nv-embedqa-e5-v5", + input: "What is the capital of France?", + }, + credentials: { apiKey: "nvidia-key" }, + log: null, + }); + + assert.equal(result.success, true); + // The model-level default input_type must be forwarded to the upstream body. + assert.equal( + captured.body?.input_type, + "query", + "expected NVIDIA asymmetric model default input_type to be injected" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleEmbedding respects a client-supplied input_type (does not overwrite)", async () => { + const originalFetch = globalThis.fetch; + const captured: { body?: Record } = {}; + globalThis.fetch = captureFetch(captured) as typeof fetch; + + try { + const result = await handleEmbedding({ + body: { + model: "nvidia/nvidia/nv-embedqa-e5-v5", + input: "Paris is the capital of France.", + input_type: "passage", + }, + credentials: { apiKey: "nvidia-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal( + captured.body?.input_type, + "passage", + "expected client-supplied input_type to be respected, not overwritten by the default" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleEmbedding does not inject input_type for symmetric models without a default", async () => { + const originalFetch = globalThis.fetch; + const captured: { body?: Record } = {}; + globalThis.fetch = captureFetch(captured) as typeof fetch; + + try { + const result = await handleEmbedding({ + body: { + model: "openai/text-embedding-3-small", + input: "hello world", + }, + credentials: { apiKey: "openai-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal( + "input_type" in (captured.body || {}), + false, + "symmetric models without a default must not receive an injected input_type" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); From d6ce094a6083cd7ed92ff6bc2c2fca59b95f6f54 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:54:42 -0300 Subject: [PATCH 05/38] fix(api): migrate deprecated Codex [features].codex_hooks to [features].hooks (#4342) Codex renamed the `codex_hooks` feature flag to `hooks`; recent Codex CLI versions ignore the old key and warn. When OmniRoute rewrites an existing config.toml (configure/reset Codex provider) it now renames [features].codex_hooks -> [features].hooks, preserving the value and never clobbering an already-present `hooks`, then drops the deprecated key. The migration is a no-op when the flag is absent and runs on both the POST and DELETE config paths. Reported-by: Bian-Sh (https://github.com/decolua/9router/issues/1327) Co-authored-by: Bian-Sh <24520547+Bian-Sh@users.noreply.github.com> --- CHANGELOG.md | 1 + src/app/api/cli-tools/codex-settings/route.ts | 7 ++++ src/shared/utils/codexConfig.ts | 30 +++++++++++++++++ .../unit/codex-config-hooks-migration.test.ts | 32 +++++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 src/shared/utils/codexConfig.ts create mode 100644 tests/unit/codex-config-hooks-migration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c250727c0cd..1fb814357234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ _In development — bullets added per PR; finalized at release._ ### 🔒 Security - **fix(mitm): exact host membership in the MITM hosts test (CodeQL false positive)** — `tests/unit/mitm-tool-hosts.test.ts` checked host membership with `Array.includes(host)`, which CodeQL's `js/incomplete-url-substring-sanitization` heuristic misreads as a `String.includes()` URL-substring sanitization test (HIGH false positive). Switched to `.some((h) => h === host)` — identical semantics, no flagged pattern. ([#4386](https://github.com/diegosouzapw/OmniRoute/pull/4386)) +- **fix(api): migrate the deprecated Codex `[features].codex_hooks` flag to `[features].hooks`** — Codex renamed the `codex_hooks` feature flag to `hooks`; recent Codex CLI versions ignore the old key and print a deprecation notice. When OmniRoute rewrites an existing `~/.codex/config.toml` (configuring/resetting the Codex provider) it now carries the user's intent forward by renaming `[features].codex_hooks` → `[features].hooks` (preserving its value, never clobbering an already-present `hooks`) and dropping the deprecated key. No-op when the flag is absent. (thanks @Bian-Sh) --- diff --git a/src/app/api/cli-tools/codex-settings/route.ts b/src/app/api/cli-tools/codex-settings/route.ts index fb4af41e6702..7e890fd8ed34 100644 --- a/src/app/api/cli-tools/codex-settings/route.ts +++ b/src/app/api/cli-tools/codex-settings/route.ts @@ -15,6 +15,7 @@ import { cliModelConfigSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { getApiKeyById } from "@/lib/localDb"; import { normalizeCodexBaseUrl } from "@/shared/utils/codexBaseUrl"; +import { migrateCodexFeatureFlags } from "@/shared/utils/codexConfig"; const getCodexConfigPath = () => getCliConfigPaths("codex").config; const getCodexAuthPath = () => getCliConfigPaths("codex").auth; @@ -252,6 +253,9 @@ export async function POST(request: Request) { /* No existing config */ } + // Carry the user's intent forward off the deprecated Codex feature flag (#1327). + migrateCodexFeatureFlags(parsed); + // Update only OmniRoute related fields (api_key goes to auth.json, not config.toml) parsed._root.model = model; @@ -351,6 +355,9 @@ export async function DELETE(request: Request) { throw error; } + // Carry the user's intent forward off the deprecated Codex feature flag (#1327). + migrateCodexFeatureFlags(parsed); + // Remove OmniRoute related root fields delete parsed._root.openai_base_url; diff --git a/src/shared/utils/codexConfig.ts b/src/shared/utils/codexConfig.ts new file mode 100644 index 000000000000..e86d5a54966e --- /dev/null +++ b/src/shared/utils/codexConfig.ts @@ -0,0 +1,30 @@ +/** + * Helpers for generating/maintaining the Codex CLI `config.toml`. + */ + +interface ParsedCodexToml { + _root: Record; + _sections: Record>; +} + +/** + * Migrate the deprecated Codex `[features].codex_hooks` flag to `[features].hooks`. + * + * Codex renamed the feature flag; recent Codex CLI versions ignore the old key and + * print a deprecation notice. When OmniRoute rewrites an existing `config.toml` it + * should carry the user's intent forward by renaming the key (preserving its value) + * and dropping the deprecated one. A no-op when `[features]` or `codex_hooks` is + * absent, and it never clobbers an already-present `hooks` value. (#1327) + * + * Operates in place on the route's parsed-TOML shape (`{ _root, _sections }`). + */ +export function migrateCodexFeatureFlags(parsed: ParsedCodexToml): ParsedCodexToml { + const features = parsed?._sections?.features; + if (!features || typeof features !== "object") return parsed; + if (!Object.prototype.hasOwnProperty.call(features, "codex_hooks")) return parsed; + if (!Object.prototype.hasOwnProperty.call(features, "hooks")) { + features.hooks = features.codex_hooks; + } + delete features.codex_hooks; + return parsed; +} diff --git a/tests/unit/codex-config-hooks-migration.test.ts b/tests/unit/codex-config-hooks-migration.test.ts new file mode 100644 index 000000000000..e4f70cf15fb0 --- /dev/null +++ b/tests/unit/codex-config-hooks-migration.test.ts @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression for port-from-9router#1327: Codex deprecated the `[features].codex_hooks` +// flag in favor of `[features].hooks`. The codex-settings generator parses an existing +// config.toml and writes it back but never migrated the deprecated key, so users with an +// old config kept a key recent Codex CLI versions ignore. +const { migrateCodexFeatureFlags } = await import("../../src/shared/utils/codexConfig.ts"); + +test("#1327: renames deprecated [features].codex_hooks to [features].hooks", () => { + const parsed = { _root: {}, _sections: { features: { codex_hooks: true } } }; + migrateCodexFeatureFlags(parsed); + assert.deepEqual(parsed._sections.features, { hooks: true }); +}); + +test("#1327: keeps an existing hooks value and removes the deprecated key", () => { + const parsed = { _root: {}, _sections: { features: { codex_hooks: true, hooks: false } } }; + migrateCodexFeatureFlags(parsed); + assert.deepEqual(parsed._sections.features, { hooks: false }); +}); + +test("#1327: leaves a config that already uses hooks untouched", () => { + const parsed = { _root: {}, _sections: { features: { hooks: true } } }; + migrateCodexFeatureFlags(parsed); + assert.deepEqual(parsed._sections.features, { hooks: true }); +}); + +test("#1327: no-op when there is no [features] section", () => { + const parsed = { _root: { model: "x" }, _sections: {} }; + migrateCodexFeatureFlags(parsed); + assert.deepEqual(parsed._sections, {}); +}); From 472284190a93d1b5402407300201f47ddffc84c6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:54:49 -0300 Subject: [PATCH 06/38] fix(translator): drop the null flush on the same-format response path (#4344) The streaming response translator's same-format fast path returned `[chunk]` unconditionally, so the end-of-stream null/flush signal (chunk === null) propagated as a literal `[null]`. Downstream this surfaced as an empty `data: null` SSE event between chunks and crashed strict clients (e.g. Factory Droid BYOK on /v1/responses). The fast path now returns `[]` for the null flush while still passing real chunks through unchanged. Reported-by: thaitryhand (https://github.com/decolua/9router/issues/1052) Co-authored-by: thaitryhand <248103256+thaitryhand@users.noreply.github.com> --- CHANGELOG.md | 1 + open-sse/translator/index.ts | 6 ++++-- .../translator-same-format-null-flush.test.ts | 20 +++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 tests/unit/translator-same-format-null-flush.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb814357234..add795cad89e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(mitm): exact host membership in the MITM hosts test (CodeQL false positive)** — `tests/unit/mitm-tool-hosts.test.ts` checked host membership with `Array.includes(host)`, which CodeQL's `js/incomplete-url-substring-sanitization` heuristic misreads as a `String.includes()` URL-substring sanitization test (HIGH false positive). Switched to `.some((h) => h === host)` — identical semantics, no flagged pattern. ([#4386](https://github.com/diegosouzapw/OmniRoute/pull/4386)) - **fix(api): migrate the deprecated Codex `[features].codex_hooks` flag to `[features].hooks`** — Codex renamed the `codex_hooks` feature flag to `hooks`; recent Codex CLI versions ignore the old key and print a deprecation notice. When OmniRoute rewrites an existing `~/.codex/config.toml` (configuring/resetting the Codex provider) it now carries the user's intent forward by renaming `[features].codex_hooks` → `[features].hooks` (preserving its value, never clobbering an already-present `hooks`) and dropping the deprecated key. No-op when the flag is absent. (thanks @Bian-Sh) +- **fix(translator): same-format response path no longer leaks a `data: null` SSE event** — the streaming response translator's same-format fast path returned `[chunk]` unconditionally, so the end-of-stream null/flush signal (`chunk === null`) propagated as a literal `[null]`. Downstream this surfaced as an empty `data: null` SSE event between chunks and crashed strict clients (e.g. Factory Droid BYOK on `/v1/responses`). The fast path now drops the null flush (returns `[]`) while still passing real chunks through unchanged. (thanks @thaitryhand) --- diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index fc73ef656f34..7eae873fce37 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -426,9 +426,11 @@ export function translateRequest( // Translate response chunk: target -> openai -> source export function translateResponse(targetFormat, sourceFormat, chunk, state) { - // If same format, return as-is + // If same format, return as-is — but never propagate the null/flush signal as a + // literal `[null]`, which leaks an empty `data: null` SSE event between chunks and + // crashes strict clients (#1052). if (sourceFormat === targetFormat) { - return [chunk]; + return chunk == null ? [] : [chunk]; } let results = [chunk]; diff --git a/tests/unit/translator-same-format-null-flush.test.ts b/tests/unit/translator-same-format-null-flush.test.ts new file mode 100644 index 000000000000..2e080c99a4c4 --- /dev/null +++ b/tests/unit/translator-same-format-null-flush.test.ts @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression for port-from-9router#1052: the streaming response translator's +// same-format fast path returned `[chunk]` unconditionally. The null/flush signal +// (chunk === null) therefore leaked a literal `[null]` to downstream consumers, +// which surfaced as an empty `data: null` SSE event between chunks and crashed +// strict clients (e.g. Factory Droid BYOK on /v1/responses). +const { translateResponse } = await import("../../open-sse/translator/index.ts"); + +test("#1052: same-format null flush yields no chunks (not [null])", () => { + const out = translateResponse("openai", "openai", null, {}); + assert.deepEqual(out, []); +}); + +test("#1052: same-format real chunk still passes through unchanged", () => { + const chunk = { id: "x", choices: [{ delta: { content: "hi" } }] }; + const out = translateResponse("openai", "openai", chunk, {}); + assert.deepEqual(out, [chunk]); +}); From aaf7e32f46d9394104e69dcaf283465fc9fb1682 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:54:56 -0300 Subject: [PATCH 07/38] fix(translator): strip assistant echo fields on the OpenAI target path (Mistral 422) (#4350) Strict OpenAI-compatible upstreams (e.g. mistral/codestral-latest) reject client-only assistant echo fields sent back as input with 422 extra_forbidden (the report hit messages[].assistant.reasoning_content via Codex /responses). Only reasoning_content was stripped on the OpenAI target path; the sibling fields reasoning / refusal / annotations / cache_control leaked through. They are now all dropped on the non-reasoner OpenAI target path. `audio` is intentionally preserved (OpenAI audio models reference a prior assistant audio response by id; Mistral never emits audio). Reported-by: xxy9468615 (https://github.com/decolua/9router/issues/1649) Co-authored-by: xxy9468615 <63351664+xxy9468615@users.noreply.github.com> --- CHANGELOG.md | 1 + open-sse/translator/index.ts | 20 +++++++- ...anslator-mistral-strip-echo-fields.test.ts | 50 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/unit/translator-mistral-strip-echo-fields.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index add795cad89e..0cdeb696c44c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(mitm): exact host membership in the MITM hosts test (CodeQL false positive)** — `tests/unit/mitm-tool-hosts.test.ts` checked host membership with `Array.includes(host)`, which CodeQL's `js/incomplete-url-substring-sanitization` heuristic misreads as a `String.includes()` URL-substring sanitization test (HIGH false positive). Switched to `.some((h) => h === host)` — identical semantics, no flagged pattern. ([#4386](https://github.com/diegosouzapw/OmniRoute/pull/4386)) - **fix(api): migrate the deprecated Codex `[features].codex_hooks` flag to `[features].hooks`** — Codex renamed the `codex_hooks` feature flag to `hooks`; recent Codex CLI versions ignore the old key and print a deprecation notice. When OmniRoute rewrites an existing `~/.codex/config.toml` (configuring/resetting the Codex provider) it now carries the user's intent forward by renaming `[features].codex_hooks` → `[features].hooks` (preserving its value, never clobbering an already-present `hooks`) and dropping the deprecated key. No-op when the flag is absent. (thanks @Bian-Sh) - **fix(translator): same-format response path no longer leaks a `data: null` SSE event** — the streaming response translator's same-format fast path returned `[chunk]` unconditionally, so the end-of-stream null/flush signal (`chunk === null`) propagated as a literal `[null]`. Downstream this surfaced as an empty `data: null` SSE event between chunks and crashed strict clients (e.g. Factory Droid BYOK on `/v1/responses`). The fast path now drops the null flush (returns `[]`) while still passing real chunks through unchanged. (thanks @thaitryhand) +- **fix(translator): strip client-only assistant echo fields on the OpenAI target path (Mistral 422)** — strict OpenAI-compatible upstreams (e.g. `mistral/codestral-latest`) reject client-only assistant "echo" fields sent back as input history with `422 extra_forbidden` (the report hit `messages[].assistant.reasoning_content` via Codex `/responses`). Only `reasoning_content` was being stripped on the OpenAI target path; the sibling echo fields `reasoning`, `refusal`, `annotations` and `cache_control` leaked through and tripped the 422. They are now all dropped on the non-reasoner OpenAI target path. `audio` is deliberately preserved (OpenAI audio models reference a prior assistant audio response by id on multi-turn; Mistral never emits audio, so nothing is lost there). (thanks @xxy9468615) --- diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 7eae873fce37..89205682787e 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -117,6 +117,20 @@ function isDeepSeekReplayTarget(provider: unknown, model: unknown): boolean { /** @param options.preserveDeveloperRole - undefined/true: keep developer for OpenAI format (default); false: map to system */ /** @param options.preserveCacheControl - When true, preserve client-side cache_control markers (for Claude Code, etc.) */ // Translate request: source -> openai -> target +// Client-only assistant "echo" fields that strict OpenAI-compatible upstreams (e.g. +// Mistral) reject with 422 extra_forbidden when sent back as input history. They carry +// no value upstream and are dropped on the OpenAI target path (#1649). `audio` is +// deliberately NOT included: OpenAI audio models reference a prior assistant audio +// response by id on multi-turn, so stripping it would break that (Mistral never emits +// audio, so it is never present there). +const OPENAI_INCOMPATIBLE_ECHO_FIELDS = [ + "reasoning_content", + "reasoning", + "refusal", + "annotations", + "cache_control", +]; + export function translateRequest( sourceFormat, targetFormat, @@ -415,8 +429,10 @@ export function translateRequest( Array.isArray(result.messages) ) { for (const msg of result.messages) { - if (msg.reasoning_content !== undefined) { - delete msg.reasoning_content; + for (const field of OPENAI_INCOMPATIBLE_ECHO_FIELDS) { + if (msg[field] !== undefined) { + delete msg[field]; + } } } } diff --git a/tests/unit/translator-mistral-strip-echo-fields.test.ts b/tests/unit/translator-mistral-strip-echo-fields.test.ts new file mode 100644 index 000000000000..6b5874020e9f --- /dev/null +++ b/tests/unit/translator-mistral-strip-echo-fields.test.ts @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression for port-from-9router#1649: strict OpenAI-compatible upstreams (e.g. +// mistral/codestral) reject client-only assistant "echo" fields on input with +// 422 extra_forbidden (the report hit `messages[].assistant.reasoning_content`). +// Only `reasoning_content` was stripped on the OpenAI target path; the sibling echo +// fields (reasoning / refusal / annotations / cache_control) leaked through. +const { translateRequest } = await import("../../open-sse/translator/index.ts"); + +test("#1649: assistant echo fields are stripped on the OpenAI target path", () => { + const body = { + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: "answer", + reasoning_content: "secret reasoning", + reasoning: { effort: "low" }, + refusal: null, + annotations: [], + cache_control: { type: "ephemeral" }, + audio: { id: "audio_123" }, + }, + ], + }; + + const out = translateRequest( + "openai", + "openai", + "mistral/codestral-latest", + body, + false, + null, + "mistral" + ) as { messages: Record[] }; + + const asst = out.messages[1]; + assert.equal(asst.reasoning_content, undefined, "reasoning_content stripped"); + assert.equal(asst.reasoning, undefined, "reasoning stripped"); + assert.equal(asst.refusal, undefined, "refusal stripped"); + assert.equal(asst.annotations, undefined, "annotations stripped"); + assert.equal(asst.cache_control, undefined, "cache_control stripped"); + // `audio` is intentionally preserved: OpenAI audio models reference a prior + // assistant audio response by id on multi-turn, and stripping it universally on + // the OpenAI path would break that. Mistral never emits audio, so nothing is lost. + assert.deepEqual(asst.audio, { id: "audio_123" }, "audio preserved"); + // The visible content is untouched. + assert.equal(asst.content, "answer"); +}); From 0773aa81c35e733ebfa8303dd8d39c42198d7216 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:55:29 -0300 Subject: [PATCH 08/38] fix(cli): honor TAILSCALE_AUTHKEY for non-interactive tailscale login (#4343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): honor TAILSCALE_AUTHKEY for non-interactive tailscale login (port from 9router#1263) startTailscaleLogin built `tailscale up` without ever reading process.env.TAILSCALE_AUTHKEY, so a pre-authenticated / headless daemon waited for an interactive auth URL and timed out (~15s). When TAILSCALE_AUTHKEY is set it is now passed via `--auth-key=` (an argv element to spawn(binary, args) — no shell interpolation, Hard Rule #13); when unset, behavior is unchanged. The arg builder is extracted into a pure exported `tailscaleUpArgs()` for testing. Reported-by: ipeterpetrus (https://github.com/decolua/9router/issues/1263) Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com> * chore(quality): rebaseline tailscaleTunnel.ts file-size to 1202 (#1263 +13) --------- Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com> --- CHANGELOG.md | 1 + config/quality/file-size-baseline.json | 3 ++- src/lib/tailscaleTunnel.ts | 23 +++++++++++++---- tests/unit/tailscale-authkey.test.ts | 35 ++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 tests/unit/tailscale-authkey.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cdeb696c44c..0e2402be2705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(api): migrate the deprecated Codex `[features].codex_hooks` flag to `[features].hooks`** — Codex renamed the `codex_hooks` feature flag to `hooks`; recent Codex CLI versions ignore the old key and print a deprecation notice. When OmniRoute rewrites an existing `~/.codex/config.toml` (configuring/resetting the Codex provider) it now carries the user's intent forward by renaming `[features].codex_hooks` → `[features].hooks` (preserving its value, never clobbering an already-present `hooks`) and dropping the deprecated key. No-op when the flag is absent. (thanks @Bian-Sh) - **fix(translator): same-format response path no longer leaks a `data: null` SSE event** — the streaming response translator's same-format fast path returned `[chunk]` unconditionally, so the end-of-stream null/flush signal (`chunk === null`) propagated as a literal `[null]`. Downstream this surfaced as an empty `data: null` SSE event between chunks and crashed strict clients (e.g. Factory Droid BYOK on `/v1/responses`). The fast path now drops the null flush (returns `[]`) while still passing real chunks through unchanged. (thanks @thaitryhand) - **fix(translator): strip client-only assistant echo fields on the OpenAI target path (Mistral 422)** — strict OpenAI-compatible upstreams (e.g. `mistral/codestral-latest`) reject client-only assistant "echo" fields sent back as input history with `422 extra_forbidden` (the report hit `messages[].assistant.reasoning_content` via Codex `/responses`). Only `reasoning_content` was being stripped on the OpenAI target path; the sibling echo fields `reasoning`, `refusal`, `annotations` and `cache_control` leaked through and tripped the 422. They are now all dropped on the non-reasoner OpenAI target path. `audio` is deliberately preserved (OpenAI audio models reference a prior assistant audio response by id on multi-turn; Mistral never emits audio, so nothing is lost there). (thanks @xxy9468615) +- **fix(cli): Tailscale login honors `TAILSCALE_AUTHKEY` for non-interactive sign-in** — `startTailscaleLogin` built `tailscale up` without ever reading `process.env.TAILSCALE_AUTHKEY`, so on a pre-authenticated / headless daemon the login waited for an interactive auth URL and timed out (~15s). When `TAILSCALE_AUTHKEY` is set it is now passed via `--auth-key=` (as a spawn argv element — no shell interpolation) so the daemon authenticates non-interactively; when unset, behavior is unchanged. (thanks @ipeterpetrus) --- diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index c6cd0f37e36e..d08e1ad6d73d 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -72,6 +72,7 @@ "_rebaseline_2026_06_17_duckduckgo_free": "Free web search (free-claude-code port, Fase 6) own growth: handlers/search.ts 1442->1546 (+104 = tryDuckDuckGoFreeProvider, a dedicated provider path mirroring the existing tryZaiMCPProvider special-case: the lite endpoint returns HTML, not the JSON the generic tryProvider()/normalizeResponse() flow expects, so it cannot reuse that path). The reusable parser + guarded fetch (parseDuckDuckGoLite, freeWebSearch) live in the new small open-sse/services/freeWebSearch.ts (well under cap). Cohesive at the per-provider dispatch chokepoint; not extractable without hiding the provider boundary.", "_rebaseline_2026_06_18_4228_combo_fallback_bailout": "PR #4228 own growth: combo.ts 2597->2601 (+4 at the existing proactive-fallback applyCompression call in handleComboChat = opt into the TV1 bail-out so a throwing fallback engine is SKIPPED instead of propagating out of executeTarget and being swallowed as a 'Speculative task error' that silently drops the combo target). minGainPercent:0 keeps the advance behavior identical to the default path — this only adds skip-on-throw. The bailout option is threaded through the (non-frozen) strategySelector.ts applyCompression signature down to applyStackedCompression. Cohesive guard at the existing fallback-compression chokepoint; not extractable without hiding the call site.", "_rebaseline_2026_06_17_stream_recovery": "Stream recovery wiring (free-claude-code port, Fase 4) own growth: chatCore.ts 5898->5980 (+82 at the existing streaming-return chokepoint: read the resolved streamRecovery.enabled setting once; when ON, a reopenStream() thunk re-executes the SAME upstream — it closes over the ~15 per-attempt executor locals (executor/provider/modelToCall/bodyToSend/upstreamStream/execCreds/extendedContext/headers builders/onCredentialsRefreshed/skipUpstreamRetry/contextEditingEnabled) — and the body is wrapped with createRecoverableStream for transparent early-retry; OFF keeps the byte-identical wrapReadableStreamWithFinalize path). All reusable logic (HoldbackBuffer, createRecoverableStream, isRetryableStreamError, hasTerminalMarker) lives in the new open-sse/services/streamRecovery.ts (well under cap). Not extractable without hiding the per-attempt executor-dispatch boundary; opt-in (default OFF). Structural shrink of chatCore.ts tracked in #3501.", + "_rebaseline_2026_06_20_1263_tailscale_authkey": "Re-baseline #1263 (honor TAILSCALE_AUTHKEY for non-interactive tailscale login): tailscaleTunnel.ts 1189->1202 (+13, exported pure tailscaleUpArgs helper + env read + wiring). Cohesive tunnel logic; not extractable.", "cap": 800, "frozen": { "open-sse/config/providerRegistry.ts": 4731, @@ -161,7 +162,7 @@ "src/lib/memory/retrieval.ts": 1171, "src/lib/modelsDevSync.ts": 934, "src/lib/providers/validation.ts": 4428, - "src/lib/tailscaleTunnel.ts": 1189, + "src/lib/tailscaleTunnel.ts": 1202, "src/lib/usage/callLogs.ts": 975, "src/lib/usage/providerLimits.ts": 949, "src/lib/usage/usageHistory.ts": 934, diff --git a/src/lib/tailscaleTunnel.ts b/src/lib/tailscaleTunnel.ts index cea5e8f4a312..54747daab898 100644 --- a/src/lib/tailscaleTunnel.ts +++ b/src/lib/tailscaleTunnel.ts @@ -324,6 +324,22 @@ function invalidateSocketCache() { _cachedActiveSocketTimestamp = 0; } +/** + * Build the `tailscale up` argument list. When an auth key is provided (e.g. from + * the `TAILSCALE_AUTHKEY` env var) it is passed via `--auth-key=` so a + * pre-authenticated / headless daemon logs in non-interactively instead of waiting + * for (and timing out on) an interactive auth URL. (#1263) The key is an argv + * element passed to `spawn(binary, args)` with no shell, so it is not shell-interpolated. + */ +export function tailscaleUpArgs(hostname?: string, authKey?: string): string[] { + return [ + "up", + "--accept-routes", + ...(hostname ? [`--hostname=${hostname}`] : []), + ...(authKey ? [`--auth-key=${authKey}`] : []), + ]; +} + async function buildTailscaleArgs(...args: string[]) { if (IS_WINDOWS) return args; const socket = await getActiveSocketPath(); @@ -619,11 +635,8 @@ export async function startTailscaleLogin({ } const resolvedHostname = toNonEmptyString(hostname) || (await getDefaultHostname()); - const spawnArgs = await buildTailscaleArgs( - "up", - "--accept-routes", - ...(resolvedHostname ? [`--hostname=${resolvedHostname}`] : []) - ); + const authKey = toNonEmptyString(process.env.TAILSCALE_AUTHKEY); + const spawnArgs = await buildTailscaleArgs(...tailscaleUpArgs(resolvedHostname, authKey)); return new Promise((resolve, reject) => { const child = spawn(resolution.binaryPath as string, spawnArgs, { diff --git a/tests/unit/tailscale-authkey.test.ts b/tests/unit/tailscale-authkey.test.ts new file mode 100644 index 000000000000..e9ad5c686e1b --- /dev/null +++ b/tests/unit/tailscale-authkey.test.ts @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression for port-from-9router#1263: `tailscale up` was built without ever reading +// process.env.TAILSCALE_AUTHKEY, so on a pre-authenticated / headless daemon the login +// waited for an interactive auth URL and timed out. The arg builder must include +// `--auth-key=` when the env var is set, and omit it otherwise. +const { tailscaleUpArgs } = await import("../../src/lib/tailscaleTunnel.ts"); + +test("#1263: includes --auth-key when an auth key is provided", () => { + const args = tailscaleUpArgs("my-host", "tskey-auth-abc123"); + assert.ok(args.includes("--auth-key=tskey-auth-abc123"), "expected --auth-key in args"); + assert.ok(args.includes("up")); + assert.ok(args.includes("--accept-routes")); + assert.ok(args.includes("--hostname=my-host")); +}); + +test("#1263: omits --auth-key when no key is provided", () => { + const args = tailscaleUpArgs("my-host", undefined); + assert.equal( + args.some((a) => a.startsWith("--auth-key")), + false, + "no auth-key arg when key is absent" + ); +}); + +test("#1263: omits --hostname when no hostname is provided", () => { + const args = tailscaleUpArgs(undefined, "tskey-auth-abc123"); + assert.equal( + args.some((a) => a.startsWith("--hostname")), + false, + "no hostname arg when hostname is absent" + ); + assert.ok(args.includes("--auth-key=tskey-auth-abc123")); +}); From 838fddacee39e6550b86a2ec5250a3f4190de699 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:55:37 -0300 Subject: [PATCH 09/38] fix(dashboard): OAuth modal surfaces the real error on a non-JSON response (#4351) * fix(dashboard): OAuth modal surfaces real error on non-JSON responses (port from 9router#1318) The OAuth connect/reauth modal called `await res.json()` unconditionally, so a non-JSON error response (e.g. a plain-text 500 page from a build/OAuth endpoint) threw `Unexpected token 'I'...` and hid the real failure. New shared helpers parseResponseBody / getErrorMessage (src/shared/utils/api.ts) read the body safely (JSON when JSON, raw text otherwise) and produce a clean message either way; every modal fetch site now uses them. Reported-by: DNNYF (https://github.com/decolua/9router/issues/1318) Co-authored-by: DNNYF <74033321+DNNYF@users.noreply.github.com> * fix(dashboard): type OAuth modal response body as Record (t11 any-budget) Switch the parseResponseBody casts from Record to Record so OAuthModal.tsx stays within its t11 explicit-any budget. getErrorMessage already takes unknown; the success paths typecheck clean under strict:false. No runtime change. --------- Co-authored-by: DNNYF <74033321+DNNYF@users.noreply.github.com> --- CHANGELOG.md | 1 + src/shared/components/OAuthModal.tsx | 38 +++++++++------------- src/shared/utils/api.ts | 47 +++++++++++++++++++++++++++ tests/unit/api-parse-response.test.ts | 34 +++++++++++++++++++ 4 files changed, 97 insertions(+), 23 deletions(-) create mode 100644 tests/unit/api-parse-response.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e2402be2705..1b84e60eef2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(translator): same-format response path no longer leaks a `data: null` SSE event** — the streaming response translator's same-format fast path returned `[chunk]` unconditionally, so the end-of-stream null/flush signal (`chunk === null`) propagated as a literal `[null]`. Downstream this surfaced as an empty `data: null` SSE event between chunks and crashed strict clients (e.g. Factory Droid BYOK on `/v1/responses`). The fast path now drops the null flush (returns `[]`) while still passing real chunks through unchanged. (thanks @thaitryhand) - **fix(translator): strip client-only assistant echo fields on the OpenAI target path (Mistral 422)** — strict OpenAI-compatible upstreams (e.g. `mistral/codestral-latest`) reject client-only assistant "echo" fields sent back as input history with `422 extra_forbidden` (the report hit `messages[].assistant.reasoning_content` via Codex `/responses`). Only `reasoning_content` was being stripped on the OpenAI target path; the sibling echo fields `reasoning`, `refusal`, `annotations` and `cache_control` leaked through and tripped the 422. They are now all dropped on the non-reasoner OpenAI target path. `audio` is deliberately preserved (OpenAI audio models reference a prior assistant audio response by id on multi-turn; Mistral never emits audio, so nothing is lost there). (thanks @xxy9468615) - **fix(cli): Tailscale login honors `TAILSCALE_AUTHKEY` for non-interactive sign-in** — `startTailscaleLogin` built `tailscale up` without ever reading `process.env.TAILSCALE_AUTHKEY`, so on a pre-authenticated / headless daemon the login waited for an interactive auth URL and timed out (~15s). When `TAILSCALE_AUTHKEY` is set it is now passed via `--auth-key=` (as a spawn argv element — no shell interpolation) so the daemon authenticates non-interactively; when unset, behavior is unchanged. (thanks @ipeterpetrus) +- **fix(dashboard): OAuth modal shows the real error on a non-JSON server response** — the OAuth connect/reauth modal called `await res.json()` unconditionally, so when a build/OAuth endpoint returned a plain-text error (e.g. a `500 Internal Server Error` page) the modal threw `Unexpected token 'I'…` and hid the real failure. Two shared helpers (`parseResponseBody` / `getErrorMessage` in `src/shared/utils/api.ts`) now read the body safely (JSON when it is JSON, raw text otherwise) and surface a clean message either way; all modal fetch sites use them. (thanks @DNNYF) --- diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 65b01a4c4852..5a498f049d87 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -6,6 +6,7 @@ import Modal from "./Modal"; import Button from "./Button"; import Input from "./Input"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { parseResponseBody, getErrorMessage } from "@/shared/utils/api"; const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "agy", "gemini-cli"]); @@ -125,7 +126,7 @@ export default function OAuthModal({ }), }); - const data = await res.json(); + const data = (await parseResponseBody(res)) as Record; if (!res.ok) { const errorObject = typeof data.error === "object" && data.error !== null @@ -188,13 +189,9 @@ export default function OAuthModal({ connectionId: reauthConnection?.id, }), }); - const data = await res.json(); + const data = (await parseResponseBody(res)) as Record; if (!res.ok) { - const errMsg = - typeof data.error === "object" && data.error !== null - ? ((data.error as Record).message as string) || - JSON.stringify(data.error) - : data.error || "Save failed"; + const errMsg = getErrorMessage(data, res.status, "Save failed"); throw new Error(errMsg); } setStep("success"); @@ -228,7 +225,7 @@ export default function OAuthModal({ }), }); - const data = await res.json(); + const data = (await parseResponseBody(res)) as Record; if (data.success) { setStep("success"); @@ -293,13 +290,9 @@ export default function OAuthModal({ } const res = await fetch(deviceCodeUrl.toString()); - const data = await res.json(); + const data = (await parseResponseBody(res)) as Record; if (!res.ok) { - const errMsg = - typeof data.error === "object" && data.error !== null - ? ((data.error as Record).message as string) || - JSON.stringify(data.error) - : data.error || "Request failed"; + const errMsg = getErrorMessage(data, res.status, "Request failed"); throw new Error(errMsg); } @@ -339,8 +332,11 @@ export default function OAuthModal({ if (isTrueLocalhost) { try { const serverRes = await fetch(`/api/oauth/${provider}/start-callback-server`); - const serverData = await serverRes.json(); - if (!serverRes.ok) throw new Error(serverData.error); + const serverData = (await parseResponseBody(serverRes)) as Record; + if (!serverRes.ok) + throw new Error( + getErrorMessage(serverData, serverRes.status, "Failed to start callback server") + ); setAuthData({ ...serverData, redirectUri: serverData.redirectUri }); setStep("waiting"); @@ -361,7 +357,7 @@ export default function OAuthModal({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ connectionId: reauthConnection?.id }), }); - const pollData = await pollRes.json(); + const pollData = (await parseResponseBody(pollRes)) as Record; if (pollData.success) { setStep("success"); @@ -432,13 +428,9 @@ export default function OAuthModal({ const res = await fetch( `/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}` ); - const data = await res.json(); + const data = (await parseResponseBody(res)) as Record; if (!res.ok) { - const errMsg = - typeof data.error === "object" && data.error !== null - ? ((data.error as Record).message as string) || - JSON.stringify(data.error) - : data.error || "Authorization failed"; + const errMsg = getErrorMessage(data, res.status, "Authorization failed"); throw new Error(errMsg); } diff --git a/src/shared/utils/api.ts b/src/shared/utils/api.ts index 711b3ac5e24e..f15eff16b3dd 100644 --- a/src/shared/utils/api.ts +++ b/src/shared/utils/api.ts @@ -48,6 +48,53 @@ export async function del(url: string, options: ApiOptions = {}) { return handleResponse(response); } +/** + * Safely read a fetch `Response` body. Returns the parsed JSON when the body is + * JSON, the raw text when it is not, or `null` when empty. Never throws on a + * non-JSON body — a plain-text `500 Internal Server Error` no longer surfaces to + * the caller as `Unexpected token 'I'…`. (#1318) The body is read exactly once, + * so pass the returned value to {@link getErrorMessage} rather than re-reading + * the response. + */ +export async function parseResponseBody(response: Response): Promise { + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} + +/** + * Extract a human-readable error message from an already-parsed response body + * (see {@link parseResponseBody}), regardless of whether it is a JSON object + * (`{ error }` / `{ error: { message } }` / `{ message }`) or plain text. Falls + * back to a status-qualified default. (#1318) + */ +export function getErrorMessage( + body: unknown, + status?: number, + fallback = "Request failed" +): string { + if (body && typeof body === "object") { + const rec = body as Record; + const err = rec.error; + if (typeof err === "string" && err.trim()) return err; + if (err && typeof err === "object") { + const message = (err as Record).message; + if (typeof message === "string" && message.trim()) return message; + return JSON.stringify(err); + } + const msg = rec.message ?? rec.detail; + if (typeof msg === "string" && msg.trim()) return msg; + } + if (typeof body === "string" && body.trim()) { + return body.length > 300 ? `${body.slice(0, 300)}…` : body; + } + return status != null ? `${fallback} (HTTP ${status})` : fallback; +} + async function handleResponse(response: Response) { const data = await response.json(); diff --git a/tests/unit/api-parse-response.test.ts b/tests/unit/api-parse-response.test.ts new file mode 100644 index 000000000000..924d315daeb7 --- /dev/null +++ b/tests/unit/api-parse-response.test.ts @@ -0,0 +1,34 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression for port-from-9router#1318: the OAuth modal called `await res.json()` +// unconditionally, so a non-JSON error response (e.g. a plain-text `Internal Server +// Error` 500 from a Build-OAuth endpoint) threw `Unexpected token 'I'…` instead of +// surfacing the real failure. The shared `parseResponseBody`/`getErrorMessage` +// helpers read the body safely and produce a clean message either way. +const { parseResponseBody, getErrorMessage } = await import("../../src/shared/utils/api.ts"); + +test("#1318: parseResponseBody returns parsed JSON for a JSON body", async () => { + const res = new Response(JSON.stringify({ error: "nope" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + assert.deepEqual(await parseResponseBody(res), { error: "nope" }); +}); + +test("#1318: parseResponseBody returns raw text for a non-JSON body (no throw)", async () => { + const res = new Response("Internal Server Error", { status: 500 }); + assert.equal(await parseResponseBody(res), "Internal Server Error"); +}); + +test("#1318: parseResponseBody returns null for an empty body", async () => { + const res = new Response("", { status: 200 }); + assert.equal(await parseResponseBody(res), null); +}); + +test("#1318: getErrorMessage handles string-error, nested-error, plain-text and fallback", () => { + assert.equal(getErrorMessage({ error: "bad key" }), "bad key"); + assert.equal(getErrorMessage({ error: { message: "expired" } }), "expired"); + assert.equal(getErrorMessage("Internal Server Error"), "Internal Server Error"); + assert.equal(getErrorMessage(null, 500, "Save failed"), "Save failed (HTTP 500)"); +}); From 6dfc6059ba5683a82a48e9d330a17f88f8d38eb9 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:57:02 -0300 Subject: [PATCH 10/38] fix(translator): accept AI SDK-style { type: image, image: data-URL } content parts (#4345) * fix(translator): accept AI SDK-style { type: image, image: "data:..." } parts (port from 9router#1330) Several OpenAI-input translators only recognized images shaped as `image_url.url` (or an object with `.source`/`.url`), so an AI SDK-style content part where `image` is a bare data-URL STRING was silently dropped before reaching a vision provider (OpenCode is one affected client; the gap is generic). The OpenAI->Claude, OpenAI->Kiro and OpenAI->Gemini/Antigravity translators now parse a string `image` data URL into each provider's native image shape (Claude base64 source, Kiro images[].source.bytes, Gemini inlineData). Reported-by: mugnimaestra (https://github.com/decolua/9router/issues/1330) Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com> * chore(quality): freeze openai-to-kiro.ts file-size at 807 (#1330 +9, over 800 cap) --------- Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com> --- CHANGELOG.md | 1 + config/quality/file-size-baseline.json | 2 + open-sse/translator/helpers/geminiHelper.ts | 2 + .../translator/request/openai-to-claude.ts | 12 ++++ open-sse/translator/request/openai-to-kiro.ts | 9 +++ .../translator-ai-sdk-image-parts.test.ts | 61 +++++++++++++++++++ 6 files changed, 87 insertions(+) create mode 100644 tests/unit/translator-ai-sdk-image-parts.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b84e60eef2c..38f17ac57cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(translator): strip client-only assistant echo fields on the OpenAI target path (Mistral 422)** — strict OpenAI-compatible upstreams (e.g. `mistral/codestral-latest`) reject client-only assistant "echo" fields sent back as input history with `422 extra_forbidden` (the report hit `messages[].assistant.reasoning_content` via Codex `/responses`). Only `reasoning_content` was being stripped on the OpenAI target path; the sibling echo fields `reasoning`, `refusal`, `annotations` and `cache_control` leaked through and tripped the 422. They are now all dropped on the non-reasoner OpenAI target path. `audio` is deliberately preserved (OpenAI audio models reference a prior assistant audio response by id on multi-turn; Mistral never emits audio, so nothing is lost there). (thanks @xxy9468615) - **fix(cli): Tailscale login honors `TAILSCALE_AUTHKEY` for non-interactive sign-in** — `startTailscaleLogin` built `tailscale up` without ever reading `process.env.TAILSCALE_AUTHKEY`, so on a pre-authenticated / headless daemon the login waited for an interactive auth URL and timed out (~15s). When `TAILSCALE_AUTHKEY` is set it is now passed via `--auth-key=` (as a spawn argv element — no shell interpolation) so the daemon authenticates non-interactively; when unset, behavior is unchanged. (thanks @ipeterpetrus) - **fix(dashboard): OAuth modal shows the real error on a non-JSON server response** — the OAuth connect/reauth modal called `await res.json()` unconditionally, so when a build/OAuth endpoint returned a plain-text error (e.g. a `500 Internal Server Error` page) the modal threw `Unexpected token 'I'…` and hid the real failure. Two shared helpers (`parseResponseBody` / `getErrorMessage` in `src/shared/utils/api.ts`) now read the body safely (JSON when it is JSON, raw text otherwise) and surface a clean message either way; all modal fetch sites use them. (thanks @DNNYF) +- **fix(translator): accept AI SDK-style `{ type: "image", image: "data:…" }` content parts** — several OpenAI-input translators only recognized images shaped as `image_url.url` (or an object with `.source`/`.url`), so an AI SDK-style part where `image` is a bare data-URL **string** was silently dropped before reaching a vision provider (OpenCode is one affected client; the gap is generic). The OpenAI→Claude, OpenAI→Kiro and OpenAI→Gemini/Antigravity translators now parse a string `image` data URL into each provider's native image shape (Claude `{source:{type:"base64"}}`, Kiro `images[].source.bytes`, Gemini `inlineData`). (thanks @mugnimaestra) --- diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index d08e1ad6d73d..e983fa34045d 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -73,8 +73,10 @@ "_rebaseline_2026_06_18_4228_combo_fallback_bailout": "PR #4228 own growth: combo.ts 2597->2601 (+4 at the existing proactive-fallback applyCompression call in handleComboChat = opt into the TV1 bail-out so a throwing fallback engine is SKIPPED instead of propagating out of executeTarget and being swallowed as a 'Speculative task error' that silently drops the combo target). minGainPercent:0 keeps the advance behavior identical to the default path — this only adds skip-on-throw. The bailout option is threaded through the (non-frozen) strategySelector.ts applyCompression signature down to applyStackedCompression. Cohesive guard at the existing fallback-compression chokepoint; not extractable without hiding the call site.", "_rebaseline_2026_06_17_stream_recovery": "Stream recovery wiring (free-claude-code port, Fase 4) own growth: chatCore.ts 5898->5980 (+82 at the existing streaming-return chokepoint: read the resolved streamRecovery.enabled setting once; when ON, a reopenStream() thunk re-executes the SAME upstream — it closes over the ~15 per-attempt executor locals (executor/provider/modelToCall/bodyToSend/upstreamStream/execCreds/extendedContext/headers builders/onCredentialsRefreshed/skipUpstreamRetry/contextEditingEnabled) — and the body is wrapped with createRecoverableStream for transparent early-retry; OFF keeps the byte-identical wrapReadableStreamWithFinalize path). All reusable logic (HoldbackBuffer, createRecoverableStream, isRetryableStreamError, hasTerminalMarker) lives in the new open-sse/services/streamRecovery.ts (well under cap). Not extractable without hiding the per-attempt executor-dispatch boundary; opt-in (default OFF). Structural shrink of chatCore.ts tracked in #3501.", "_rebaseline_2026_06_20_1263_tailscale_authkey": "Re-baseline #1263 (honor TAILSCALE_AUTHKEY for non-interactive tailscale login): tailscaleTunnel.ts 1189->1202 (+13, exported pure tailscaleUpArgs helper + env read + wiring). Cohesive tunnel logic; not extractable.", + "_rebaseline_2026_06_20_1330_ai_sdk_image": "Re-baseline #1330 (accept AI SDK-style {type:image, image:data-URL string} parts): openai-to-kiro.ts 798->807 (+9, new image-part branch mirroring the existing image_url handling), crossing the 800 cap. Freeze at 807. Cohesive translator branch; the other two translators (claude 776, gemini 630) stay under cap.", "cap": 800, "frozen": { + "open-sse/translator/request/openai-to-kiro.ts": 807, "open-sse/config/providerRegistry.ts": 4731, "open-sse/executors/antigravity.ts": 1680, "open-sse/executors/base.ts": 1387, diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 23f6d82ecff8..38b462000e1c 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -158,6 +158,8 @@ export function convertOpenAIContentToParts(content: unknown): JsonRecord[] { // translation layers as an alternative to `rec.image_url` (#2807). const fileData = (typeof rec.file_url === "string" ? rec.file_url : undefined) || + // AI SDK-style image part: { type: "image", image: "data:...;base64,..." } (#1330) + (typeof rec.image === "string" ? rec.image : undefined) || imageUrl?.url || imageObj?.url || fileUrl?.url || diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index b770a80bfda7..4ec6236f75ef 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -578,6 +578,18 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr } } else if (part.type === "image" && part.source) { blocks.push({ type: "image", source: part.source }); + } else if (part.type === "image" && typeof part.image === "string") { + // AI SDK-style image part: { type: "image", image: "data:...;base64,..." } (#1330) + const url = part.image; + const match = url.match(/^data:([^;]+);base64,(.+)$/); + if (match) { + blocks.push({ + type: "image", + source: { type: "base64", media_type: match[1], data: match[2] }, + }); + } else if (url.trim()) { + blocks.push({ type: "image", source: { type: "url", url } }); + } } } } diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 4483fbd98e56..3b6052dbcbda 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -265,6 +265,15 @@ function convertMessages(messages, tools, model) { const format = (block.source.media_type || "image/jpeg").split("/")[1] || "jpeg"; if (block.source.data) pendingImages.push({ format, source: { bytes: block.source.data } }); + } else if (block.type === "image" && typeof block.image === "string") { + // AI SDK-style image part: { type: "image", image: "data:...;base64,..." } (#1330) + const url = block.image; + if (url.startsWith("data:")) { + const [header, bytes] = url.split(",", 2); + const mediaType = header.split(";")[0].replace("data:", ""); + const format = mediaType.split("/")[1] || "jpeg"; + if (bytes) pendingImages.push({ format, source: { bytes } }); + } } } diff --git a/tests/unit/translator-ai-sdk-image-parts.test.ts b/tests/unit/translator-ai-sdk-image-parts.test.ts new file mode 100644 index 000000000000..b78f0375b08e --- /dev/null +++ b/tests/unit/translator-ai-sdk-image-parts.test.ts @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression for port-from-9router#1330: AI SDK-style image content parts shaped as +// `{ type: "image", image: "data:image/png;base64,..." }` (a STRING, not an object +// with `.url`/`.source`) were dropped by the OpenAI-input translators, so any client +// sending images this way (e.g. OpenCode) lost them before reaching a vision provider. +const gemini = await import("../../open-sse/translator/helpers/geminiHelper.ts"); +const claude = await import("../../open-sse/translator/request/openai-to-claude.ts"); +const kiro = await import("../../open-sse/translator/request/openai-to-kiro.ts"); + +const DATA_URL = "data:image/png;base64,AAAABBBB"; + +test("#1330: Gemini maps AI SDK image string to inlineData", () => { + const parts = gemini.convertOpenAIContentToParts([ + { type: "text", text: "describe" }, + { type: "image", image: DATA_URL }, + ]); + const inline = parts.find((p) => (p as { inlineData?: unknown }).inlineData); + assert.ok(inline, "expected an inlineData part"); + assert.deepEqual((inline as { inlineData: unknown }).inlineData, { + mimeType: "image/png", + data: "AAAABBBB", + }); +}); + +test("#1330: Claude maps AI SDK image string to a base64 image source block", () => { + const out = claude.openaiToClaudeRequest( + "claude-sonnet-4-6", + { + messages: [ + { role: "user", content: [{ type: "image", image: DATA_URL }] }, + ], + }, + false + ) as { messages: { content: { type: string; source?: Record }[] }[] }; + + const block = out.messages[0].content.find((b) => b.type === "image"); + assert.ok(block, "expected an image block"); + assert.deepEqual(block!.source, { + type: "base64", + media_type: "image/png", + data: "AAAABBBB", + }); +}); + +test("#1330: Kiro maps AI SDK image string to userInputMessage.images bytes", () => { + const payload = kiro.buildKiroPayload( + "kr/claude-sonnet-4.6", + { + messages: [ + { role: "user", content: [{ type: "text", text: "describe" }, { type: "image", image: DATA_URL }] }, + ], + }, + false, + {} + ); + const serialized = JSON.stringify(payload); + assert.ok(serialized.includes('"AAAABBBB"'), "expected the image bytes in the Kiro payload"); + assert.ok(serialized.includes('"png"'), "expected the png format in the Kiro payload"); +}); From 31f91cd23f31a79fdc2a980881f67746d3a47f45 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:58:05 -0300 Subject: [PATCH 11/38] fix(dashboard): show a disabled connection's last error in the row (#4352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): show a disabled connection's last error in the row (port from 9router#1447) The provider card's error badge counts a disabled connection (isActive === false) that has an error — its effective status is still error/expired/unavailable — but the connection row hid the lastError text for disabled rows, so the operator saw the count without the cause. The row's error-visibility decision is extracted into shouldShowConnectionLastError() and now shows the error whenever there is one, regardless of the active toggle. Reported-by: ntdung6868 (https://github.com/decolua/9router/issues/1447) Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * chore(quality): rebaseline ConnectionRow.tsx file-size to 942 (#1447 +1 import) --------- Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> --- CHANGELOG.md | 1 + config/quality/file-size-baseline.json | 3 +- .../[id]/components/ConnectionRow.tsx | 3 +- .../[id]/components/connectionRowHelpers.ts | 15 ++++++++++ ...nnection-row-last-error-visibility.test.ts | 29 +++++++++++++++++++ 5 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/connectionRowHelpers.ts create mode 100644 tests/unit/connection-row-last-error-visibility.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f17ac57cdf..c9dad90e133c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(cli): Tailscale login honors `TAILSCALE_AUTHKEY` for non-interactive sign-in** — `startTailscaleLogin` built `tailscale up` without ever reading `process.env.TAILSCALE_AUTHKEY`, so on a pre-authenticated / headless daemon the login waited for an interactive auth URL and timed out (~15s). When `TAILSCALE_AUTHKEY` is set it is now passed via `--auth-key=` (as a spawn argv element — no shell interpolation) so the daemon authenticates non-interactively; when unset, behavior is unchanged. (thanks @ipeterpetrus) - **fix(dashboard): OAuth modal shows the real error on a non-JSON server response** — the OAuth connect/reauth modal called `await res.json()` unconditionally, so when a build/OAuth endpoint returned a plain-text error (e.g. a `500 Internal Server Error` page) the modal threw `Unexpected token 'I'…` and hid the real failure. Two shared helpers (`parseResponseBody` / `getErrorMessage` in `src/shared/utils/api.ts`) now read the body safely (JSON when it is JSON, raw text otherwise) and surface a clean message either way; all modal fetch sites use them. (thanks @DNNYF) - **fix(translator): accept AI SDK-style `{ type: "image", image: "data:…" }` content parts** — several OpenAI-input translators only recognized images shaped as `image_url.url` (or an object with `.source`/`.url`), so an AI SDK-style part where `image` is a bare data-URL **string** was silently dropped before reaching a vision provider (OpenCode is one affected client; the gap is generic). The OpenAI→Claude, OpenAI→Kiro and OpenAI→Gemini/Antigravity translators now parse a string `image` data URL into each provider's native image shape (Claude `{source:{type:"base64"}}`, Kiro `images[].source.bytes`, Gemini `inlineData`). (thanks @mugnimaestra) +- **fix(dashboard): a disabled connection's last error is now visible** — the provider card's error badge counts a disabled connection (`isActive === false`) that has an error (its effective status is still error/expired/unavailable), but the connection row hid the `lastError` text for disabled rows — so the operator saw the error count without being able to see what failed. The row now shows the error text whenever there is one, regardless of the active toggle. (thanks @ntdung6868) --- diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index e983fa34045d..77ecee1c3bf0 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -74,6 +74,7 @@ "_rebaseline_2026_06_17_stream_recovery": "Stream recovery wiring (free-claude-code port, Fase 4) own growth: chatCore.ts 5898->5980 (+82 at the existing streaming-return chokepoint: read the resolved streamRecovery.enabled setting once; when ON, a reopenStream() thunk re-executes the SAME upstream — it closes over the ~15 per-attempt executor locals (executor/provider/modelToCall/bodyToSend/upstreamStream/execCreds/extendedContext/headers builders/onCredentialsRefreshed/skipUpstreamRetry/contextEditingEnabled) — and the body is wrapped with createRecoverableStream for transparent early-retry; OFF keeps the byte-identical wrapReadableStreamWithFinalize path). All reusable logic (HoldbackBuffer, createRecoverableStream, isRetryableStreamError, hasTerminalMarker) lives in the new open-sse/services/streamRecovery.ts (well under cap). Not extractable without hiding the per-attempt executor-dispatch boundary; opt-in (default OFF). Structural shrink of chatCore.ts tracked in #3501.", "_rebaseline_2026_06_20_1263_tailscale_authkey": "Re-baseline #1263 (honor TAILSCALE_AUTHKEY for non-interactive tailscale login): tailscaleTunnel.ts 1189->1202 (+13, exported pure tailscaleUpArgs helper + env read + wiring). Cohesive tunnel logic; not extractable.", "_rebaseline_2026_06_20_1330_ai_sdk_image": "Re-baseline #1330 (accept AI SDK-style {type:image, image:data-URL string} parts): openai-to-kiro.ts 798->807 (+9, new image-part branch mirroring the existing image_url handling), crossing the 800 cap. Freeze at 807. Cohesive translator branch; the other two translators (claude 776, gemini 630) stay under cap.", + "_rebaseline_2026_06_20_1447_disabled_conn_error": "Re-baseline #1447 (show a disabled connection's last error): ConnectionRow.tsx 941->942 (+1), a single import line for the extracted shouldShowConnectionLastError helper. Minimal, not extractable.", "cap": 800, "frozen": { "open-sse/translator/request/openai-to-kiro.ts": 807, @@ -125,7 +126,7 @@ "src/app/(dashboard)/dashboard/health/page.tsx": 1091, "src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847, "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 784, - "src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 941, + "src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 942, "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 866, "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1204, "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 954, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx index 36fedfe74c04..2487e4d80a8c 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx @@ -10,6 +10,7 @@ import { Badge, Button, Toggle } from "@/shared/components"; import { pickDisplayValue } from "@/shared/utils/maskEmail"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage"; +import { shouldShowConnectionLastError } from "./connectionRowHelpers"; import { getCodexEffectiveServiceTier, type CodexGlobalServiceMode, @@ -589,7 +590,7 @@ export default function ConnectionRow({ {t(statusPresentation.errorBadge.labelKey)} )} - {connection.lastError && connection.isActive !== false && ( + {shouldShowConnectionLastError(connection) && (