From c4280433c56e70d950b6b8366d24bc5e0313e7db Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 28 Jun 2026 08:08:33 -0300 Subject: [PATCH 01/61] chore(release): open v3.8.40 development cycle --- CHANGELOG.md | 6 ++++++ 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/i18n/zh-TW/CHANGELOG.md | 6 ++++++ docs/openapi.yaml | 2 +- electron/package-lock.json | 4 ++-- electron/package.json | 2 +- open-sse/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 49 files changed, 267 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eea092fe8a05..a37c343a917b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 8a8a9a51b40d..087e64ebf6f2 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 4b4b415ca652..d583b8d582e6 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 4b4b415ca652..d583b8d582e6 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 4db375f7a0ed..7adb5f70bb8c 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index f0273bf2f209..e59a3dbca0ef 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index a8a404b6cc76..9bbabe6101fe 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 1baa62d5b505..45c91a5b1375 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index d837dcc903bc..fcff02f64b46 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 2b86f44e53c5..aee9735728fc 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index 9300b6680048..49842588ea31 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 1331d8da54c9..825c34cc1d61 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index c761b5708a88..f6e5ead5951d 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index a064e00a6b66..d181bc7feff2 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 2f4ba1b8ea2d..2795959e419a 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 316d101d8060..ae0251f78e18 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index eeeed544c64e..1f27dc2b5670 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 517e670fb33e..2ce2ff474b88 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index ea8cb874f183..e0f91f953434 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 979dfe4305da..a7a5f7369b24 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index deb70296459f..2faae8fb885b 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index bb0ff2a084c9..d2ff5aec98f9 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 87b0b8d682af..459c22356a6b 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index 92b4b909ca50..f5a1772f41d8 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index 9e87b7398333..7fe8d5831bb5 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index ef478ec81bc2..d8b07016034b 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 9b661f9a4e20..87cdda0529a4 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 1060a9480387..22fb67457617 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index ca54f205dad6..322ba2ca8f0e 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index b5ac8eab601d..4f202414ec68 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 68ad9dfba8b9..22f81c0a07cb 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index c4bc53a1c598..3f524876c7c8 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 8f85b480e28c..aeb94c25f2ef 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index e8fe8a7e978b..e4cfb9ed8942 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index e96f9bb72763..23d1e60dbea5 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 790f8da00cf6..a5799a736c9f 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 7a995d33da84..c5451017ce9c 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index 69faad4662c0..f35465af59d7 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index f4e2c7c5398c..e7f2e2114c7e 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 2d6c639626b0..0009df73f820 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 1a5eb1ab7894..5fe434d6ed56 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 98ef8411554c..3e9214c048dd 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/i18n/zh-TW/CHANGELOG.md b/docs/i18n/zh-TW/CHANGELOG.md index eaa57554f289..798ad641bf35 100644 --- a/docs/i18n/zh-TW/CHANGELOG.md +++ b/docs/i18n/zh-TW/CHANGELOG.md @@ -6,6 +6,12 @@ ## [3.8.31] — 2026-06-20 +## [3.8.40] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.39] — 2026-06-28 ### ✨ New Features diff --git a/docs/openapi.yaml b/docs/openapi.yaml index dd16995633d8..c2354583109e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.39 + version: 3.8.40 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 b3ef3fe40a79..0fac1fade9c4 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.39", + "version": "3.8.40", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.39", + "version": "3.8.40", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/electron/package.json b/electron/package.json index 07440c2a1273..f3c67c0184d7 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.39", + "version": "3.8.40", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/package.json b/open-sse/package.json index 973f33de569c..bfc331719e25 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.39", + "version": "3.8.40", "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 3a038348fb0e..19b80e3ccdec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.39", + "version": "3.8.40", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.39", + "version": "3.8.40", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -28583,7 +28583,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.39", + "version": "3.8.40", "dependencies": { "@toon-format/toon": "^2.3.0", "safe-regex": "^2.1.1" diff --git a/package.json b/package.json index 2c88af470bfb..e6b2be3fb3cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.39", + "version": "3.8.40", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { From 8da9e6ad3f4f80fb589d29f7839b1bf9be71e260 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:23:38 -0300 Subject: [PATCH 02/61] chore(docker): harden base image against container-scan CVEs (#5229) apt-get upgrade -y in the base stage pulls security-patched trixie packages at build time, and npm install -g npm@latest refreshes the globally-bundled undici/tar inside the npm CLI. Together these clear the subset of GitHub container-scan CVE alerts that have an upstream fix available. None of the flagged CVEs are in the application dependency tree (app already resolves undici@8.5.0 / tar@7.5.16, both fixed); they live in the node:24-trixie-slim base layer and npm's own internals, and none are reachable from the proxy request surface at runtime. CVEs without a published fix (local-only TOCTOU, etc.) remain until the distro patches them and the image is rebuilt. --- Dockerfile | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Dockerfile b/Dockerfile index b9cab220f6ed..89d0c2fad520 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,28 @@ FROM node:24-trixie-slim AS base WORKDIR /app +# `apt-get upgrade` pulls the security-patched versions of the Debian (trixie) +# base-image packages at build time — clears the subset of container-scan CVEs +# (perl / util-linux / systemd / ncurses / zlib / tar / sqlite / shadow / pam …) +# that already have a fix published in trixie. CVEs without an upstream fix yet +# (local-only TOCTOU, etc.) remain until the distro patches them and the image +# is rebuilt; none are reachable from the proxy's request surface at runtime. RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \ --mount=type=cache,target=/var/lib/apt/lists,sharing=shared \ apt-get update \ + && apt-get upgrade -y \ && apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \ && rm -rf /var/lib/apt/lists/* +# Refresh the globally-installed npm so its *bundled* node_modules (undici, tar) +# ship the patched versions. These are npm's own internals — not application +# dependencies (our app already resolves undici@8.5.0 / tar@7.5.16, both fixed) — +# but the container scanner flags the stale copies under +# /usr/local/lib/node_modules/npm/node_modules. npm is not invoked at runtime in +# the runner stages, so this is hygiene, not an exploitable runtime path. +RUN npm install -g npm@latest \ + && npm cache clean --force + # ── Builder ──────────────────────────────────────────────────────────────── FROM base AS builder From 051285a66065ee8bebe51402aba3017d64e3d06c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:29:45 -0300 Subject: [PATCH 03/61] test(docker): skip comment lines in #4076 builder heap-ordering check (#5233) The ordering assertion matched a builder-stage comment that mentions 'npm run build' (added with the v3.8.40 workspace-deps Docker fix), making it false-fail even though the real RUN step correctly follows the NODE_OPTIONS heap line. Match real ENV/RUN instructions only, ignoring '#' comment lines. --- CHANGELOG.md | 1 + tests/unit/dockerfile-build-heap-4076.test.ts | 20 ++++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a37c343a917b..0f013f9923bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ _In development — bullets added per PR; finalized at release._ ### 📝 Maintenance +- **test(docker): de-brittle the #4076 builder-stage heap-ordering test (false-failing on a comment)** — `dockerfile-build-heap-4076.test.ts` located the `npm run build` step via a raw `findIndex(/npm run build\b/)`, which matched a **comment line** in the builder stage (`# … npm run build fails with "Module not…"`, added with the v3.8.40 workspace-deps Docker fix) sitting before the `NODE_OPTIONS` heap line — making the test report the heap ceiling as set *after* the build and fail, even though the real `RUN … npm run build` correctly follows the `ENV NODE_OPTIONS` line. Instruction matching now skips Dockerfile comment lines (`# …`), so the ordering guard checks real `ENV`/`RUN` instructions only. The Dockerfile itself was already correct; this was a base-red blocking every PR into `release/v3.8.40`. ([#4076](https://github.com/diegosouzapw/OmniRoute/issues/4076)) - **test(combo): deterministic context-relay universal-handoff coverage** — covers the universal (provider-agnostic) session-handoff path in `context-relay` (`combo.ts:2099–2139`), which previously had only a definition-order assertion and a `TODO(phase-2)`. The test drives the real pipeline via session seams (`x-session-id` → `relayOptions.sessionId` → `maybeGenerateUniversalHandoff`) without live infrastructure. ([#5168](https://github.com/diegosouzapw/OmniRoute/pull/5168)) - **test(combo): end-to-end quota-share DRR routing-decision coverage (matrix parity)** — adds the missing E2E test for the `quota-share` strategy, driving the real `handleChat` → chatCore → `selectQuotaShareTarget` → executor pipeline via in-process seams and asserting which connection is dispatched. The DRR selector already had 29 unit tests; this closes the E2E gap and brings quota-share to parity with the 17-strategy public matrix. ([#5179](https://github.com/diegosouzapw/OmniRoute/pull/5179)) - **test(combo): deterministic context-relay codex quota-handoff coverage (closes last gap)** — covers the codex-specific handoff block of `context-relay` (`combo.ts:2143–2183`), which #5168 left documented-but-untested because it requires a `codex` connection. All seams (`fetchCodexQuota`, handoff generation, session relay) are mocked deterministically without live infra. ([#5195](https://github.com/diegosouzapw/OmniRoute/pull/5195)) diff --git a/tests/unit/dockerfile-build-heap-4076.test.ts b/tests/unit/dockerfile-build-heap-4076.test.ts index 2d5b1b9fb365..58301b62ffed 100644 --- a/tests/unit/dockerfile-build-heap-4076.test.ts +++ b/tests/unit/dockerfile-build-heap-4076.test.ts @@ -32,12 +32,20 @@ function builderStageRange(): { start: number; end: number } { return { start, end }; } +/** + * Index of the first *instruction* line matching `re`, ignoring Dockerfile + * comment lines (those whose trimmed text starts with `#`). #4076: a comment in + * the builder stage that merely mentions `npm run build` must not be mistaken for + * the real `RUN … npm run build` step when checking instruction ordering. + */ +function findInstructionIndex(stage: string[], re: RegExp): number { + return stage.findIndex((l) => !l.trim().startsWith("#") && re.test(l)); +} + test("#4076 builder stage raises the Node heap ceiling via NODE_OPTIONS", () => { const { start, end } = builderStageRange(); const stage = lines.slice(start, end); - const heapLineIdx = stage.findIndex( - (l) => /NODE_OPTIONS/.test(l) && /--max-old-space-size/.test(l) - ); + const heapLineIdx = findInstructionIndex(stage, /NODE_OPTIONS.*--max-old-space-size/); assert.ok( heapLineIdx >= 0, "builder stage must set NODE_OPTIONS with --max-old-space-size to avoid the #4076 build OOM" @@ -47,10 +55,8 @@ test("#4076 builder stage raises the Node heap ceiling via NODE_OPTIONS", () => test("#4076 the heap ceiling is set BEFORE `npm run build` so it reaches `next build`", () => { const { start, end } = builderStageRange(); const stage = lines.slice(start, end); - const heapLineIdx = stage.findIndex( - (l) => /NODE_OPTIONS/.test(l) && /--max-old-space-size/.test(l) - ); - const buildLineIdx = stage.findIndex((l) => /npm run build\b/.test(l)); + const heapLineIdx = findInstructionIndex(stage, /NODE_OPTIONS.*--max-old-space-size/); + const buildLineIdx = findInstructionIndex(stage, /npm run build\b/); assert.ok(buildLineIdx >= 0, "builder stage must run `npm run build`"); assert.ok(heapLineIdx >= 0, "builder stage must set the heap ceiling"); assert.ok( From 7854b1eef1e30c2b5228efc4cc5f2cce37dd57ed Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:31:43 -0300 Subject: [PATCH 04/61] chore(ci): Trivy advisory scan ignores unfixed CVEs (Security-tab noise) (#5235) The advisory Trivy image scan uploaded every HIGH/CRITICAL into the Security tab without ignore-unfixed, flooding it with ~150 unfixable base-image OS CVEs (Debian trixie packages with no upstream patch, overwhelmingly local-only and not reachable from the proxy request surface). Operators cannot act on those, so they are pure noise. Add ignore-unfixed:true to the advisory step so it mirrors the existing CRITICAL blocking gate and surfaces only actionable, fixable vulnerabilities. Wire trivyignores to a new repo-root .trivyignore that documents the accepted-risk policy and is the single auditable home for the rare fixable CVE we must temporarily accept (none at present). Takes effect on the next release image build (Trivy only runs on tag builds, not main pushes); fixed CVEs drop out of the SARIF and GitHub auto-resolves the corresponding alerts. --- .github/workflows/docker-publish.yml | 11 +++++++++++ .trivyignore | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 .trivyignore diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ec91e0c229e8..c21f2330517a 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -344,6 +344,15 @@ jobs: # Visibility scan: reports HIGH + CRITICAL into the SARIF (Security tab) but # never blocks (exit-code 0). The blocking gate below narrows to CRITICAL. + # + # ignore-unfixed mirrors the blocking gate: the Security tab must surface only + # ACTIONABLE vulnerabilities — ones with a published fix we can pull by rebuilding + # on a patched base or bumping the dep. Without it the advisory upload floods the + # tab with unfixable base-image OS CVEs (Debian trixie packages with no upstream + # patch yet, overwhelmingly local-only and not reachable from the proxy request + # surface), which is noise an operator cannot act on. trivyignores points at the + # repo-root .trivyignore so accepted-risk fixable CVEs have one auditable home. + # See docs/security/SUPPLY_CHAIN.md. - name: Trivy image scan (SARIF, advisory) if: needs.prepare.outputs.version != 'main' continue-on-error: true @@ -353,6 +362,8 @@ jobs: format: sarif output: trivy-results.sarif severity: HIGH,CRITICAL + ignore-unfixed: true + trivyignores: .trivyignore exit-code: "0" # BLOCKING gate (v3.8.27 cycle-end): fail the release on a CRITICAL CVE in the diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 000000000000..b047a520a26c --- /dev/null +++ b/.trivyignore @@ -0,0 +1,22 @@ +# .trivyignore — accepted-risk suppressions for the container image scan +# +# Policy (see docs/security/SUPPLY_CHAIN.md): +# - The Trivy steps in .github/workflows/docker-publish.yml run with +# `ignore-unfixed: true`, so vulnerabilities WITHOUT a published fix are +# already excluded from both the blocking CRITICAL gate and the advisory +# Security-tab upload. You do NOT need an entry here for an unfixable +# base-image OS CVE — it will not be reported. +# - This file is the single auditable home for the rare case where a *fixable* +# CVE must be temporarily accepted (e.g. the upstream fix is not yet in the +# pinned base tag, or the affected package/binary is provably unreachable +# from the proxy request surface and rebuilding now is not justified). +# +# Format — one CVE id per line, each with a justification comment and, where +# possible, an expiry, e.g.: +# # CVE-XXXX-YYYY — ; revisit on next base-image bump (YYYY-MM-DD) +# CVE-XXXX-YYYY +# +# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a +# patched base / bump the dep) over suppressing. Stale entries are debt. +# +# (No accepted-risk suppressions at present — ignore-unfixed covers the noise.) From 5f4cd53b6e6266eb30a1eac92a98dd3d4c4ba342 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:54:33 -0400 Subject: [PATCH 05/61] Scope textual thinking tag extraction (#5224) Scope textual thinking-tag extraction to tag-native model families; preserve GEMINI_CLI registration. Resubmit of #5216 without the regression. Integrated into release/v3.8.40. --- open-sse/handlers/chatCore.ts | 11 +- open-sse/handlers/responseSanitizer.ts | 112 ++++++++++++------ open-sse/services/contextManager.ts | 30 +---- open-sse/transformer/responsesTransformer.ts | 57 +++++---- open-sse/translator/index.ts | 4 +- .../translator/response/gemini-to-openai.ts | 33 ++++-- .../translator/response/kiro-to-openai.ts | 3 +- .../translator/response/openai-responses.ts | 58 ++++----- tests/unit/context-manager.test.ts | 32 +++++ tests/unit/response-sanitizer.test.ts | 108 ++++++++++++----- tests/unit/responses-transformer.test.ts | 31 +++-- .../unit/responses-translation-fixes.test.ts | 36 +++++- ...resp-antigravity-thinking-boundary.test.ts | 75 ++++++++++++ .../translator-resp-kiro-to-openai.test.ts | 5 +- .../translator-resp-openai-responses.test.ts | 30 ++++- 15 files changed, 451 insertions(+), 174 deletions(-) create mode 100644 tests/unit/translator-resp-antigravity-thinking-boundary.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d9b46a6d8da9..15d34b6b60d8 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -234,7 +234,11 @@ import { buildCodexQuotaPersistence } from "./chatCore/codexQuota.ts"; import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts"; import { translateNonStreamingResponse } from "./responseTranslator.ts"; import { extractUsageFromResponse } from "./usageExtractor.ts"; -import { sanitizeOpenAIResponse, sanitizeResponsesApiResponse } from "./responseSanitizer.ts"; +import { + sanitizeOpenAIResponse, + sanitizeResponsesApiResponse, + shouldParseTextualReasoningTags, +} from "./responseSanitizer.ts"; import { withRateLimit, updateFromHeaders, @@ -3613,7 +3617,10 @@ export async function handleChatCore({ // that non-standard field. Reasoning replay cache is captured above this // sanitize step, so the cache feature is unaffected. const stripReasoning = isStripReasoningRequested(clientRawRequest?.headers ?? null); - translatedResponse = sanitizeOpenAIResponse(translatedResponse, { stripReasoning }); + translatedResponse = sanitizeOpenAIResponse(translatedResponse, { + stripReasoning, + parseTextualReasoningTags: shouldParseTextualReasoningTags(provider, model), + }); } applyClientUsageBuffer(translatedResponse, body, clientResponseFormat); diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 62e165f40c71..aaa216569b2a 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -10,7 +10,7 @@ import { normalizeOpenAICompatibleFinishReason } from "../utils/finishReason.ts" * Fixes Issues: * 1. Strips non-standard fields (x_groq, usage_breakdown, service_tier) that * break OpenAI Python SDK v1.83+ Pydantic validation (returns str instead of object) - * 2. Extracts tags from thinking models into reasoning_content + * 2. Optionally extracts native textual reasoning tags from known tag-style models * 3. Normalizes response id, object, and usage fields * 4. Converts developer role → system for non-OpenAI providers */ @@ -32,6 +32,7 @@ const ALLOWED_RESPONSES_USAGE_FIELDS = new Set([ ]); type JsonRecord = Record; +type ParseOptions = { parseTextualReasoningTags?: boolean }; export const OMIT_STREAMING_CHUNK_MARKER = "__omniroute_omit_streaming_chunk"; @@ -297,6 +298,37 @@ export function extractThinkingFromContent(text: string): { }; } +function normalizeReasoningRouteId(value: unknown): string { + return typeof value === "string" ? value.toLowerCase() : ""; +} + +function isAntigravityReasoningRoute(providerId: string, modelId: string): boolean { + return ( + providerId.includes("antigravity") || + providerId === "agy" || + modelId.includes("antigravity/") || + modelId.startsWith("agy/") + ); +} + +function isTextualReasoningTagNativeRoute(providerId: string, modelId: string): boolean { + const routeId = `${providerId}/${modelId}`; + return ( + /deepseek[-_/]?r1\b/.test(routeId) || + /r1[-_/]?distill\b/.test(routeId) || + /(?:^|[/:_-])qwq(?:[/._:-]|$)/.test(routeId) + ); +} + +export function shouldParseTextualReasoningTags(provider?: unknown, model?: unknown): boolean { + const providerId = normalizeReasoningRouteId(provider); + const modelId = normalizeReasoningRouteId(model); + return ( + !isAntigravityReasoningRoute(providerId, modelId) && + isTextualReasoningTagNativeRoute(providerId, modelId) + ); +} + /** * Sanitize a non-streaming OpenAI ChatCompletion response. * Strips non-standard fields and normalizes required fields. @@ -311,6 +343,12 @@ export interface SanitizeOpenAIResponseOptions { * Ported from upstream 9router#517 (closes upstream #509). */ stripReasoning?: boolean; + /** + * Keep disabled for generic OpenAI-compatible responses: prompt-format tags + * can be user-requested visible content. Enable only for routes/models whose + * upstream contract uses textual tags as the native reasoning channel. + */ + parseTextualReasoningTags?: boolean; } export function sanitizeOpenAIResponse( @@ -320,6 +358,7 @@ export function sanitizeOpenAIResponse( const bodyRecord = toRecord(body); if (!bodyRecord) return body; const stripReasoning = options.stripReasoning === true; + const parseTextualReasoningTags = options.parseTextualReasoningTags === true; // Build sanitized response with only allowed top-level fields const sanitized: JsonRecord = {}; @@ -333,7 +372,7 @@ export function sanitizeOpenAIResponse( // Sanitize choices if (Array.isArray(bodyRecord.choices)) { sanitized.choices = bodyRecord.choices.map((choice, idx) => { - const sanitizedChoice = sanitizeChoice(choice, idx); + const sanitizedChoice = sanitizeChoice(choice, idx, { parseTextualReasoningTags }); const message = toRecord(sanitizedChoice.message); if ( message && @@ -426,7 +465,11 @@ export function sanitizeResponsesApiResponse(body: unknown): unknown { /** * Sanitize a single choice object. */ -function sanitizeChoice(choice: unknown, defaultIndex: number): JsonRecord { +function sanitizeChoice( + choice: unknown, + defaultIndex: number, + options: ParseOptions = {} +): JsonRecord { const choiceRecord = toRecord(choice); const sanitized: JsonRecord = { index: defaultIndex, @@ -441,15 +484,13 @@ function sanitizeChoice(choice: unknown, defaultIndex: number): JsonRecord { sanitized.finish_reason = normalizeOpenAICompatibleFinishReason(choiceRecord.finish_reason); } - // Sanitize message (non-streaming) or delta (streaming) if (choiceRecord?.message !== undefined) { - sanitized.message = sanitizeMessage(choiceRecord.message); + sanitized.message = sanitizeMessage(choiceRecord.message, options); } if (choiceRecord?.delta !== undefined) { - sanitized.delta = sanitizeMessage(choiceRecord.delta); + sanitized.delta = sanitizeMessage(choiceRecord.delta, options); } - // Keep logprobs if present if (choiceRecord?.logprobs !== undefined) { sanitized.logprobs = choiceRecord.logprobs; } @@ -457,37 +498,23 @@ function sanitizeChoice(choice: unknown, defaultIndex: number): JsonRecord { return sanitized; } -/** - * Sanitize a message object, extracting tags if present. - */ -function sanitizeMessage(msg: unknown): unknown { - const msgRecord = toRecord(msg); - if (!msgRecord) return msg; - - const sanitized: JsonRecord = {}; - - // Copy only allowed fields - if (msgRecord.role) sanitized.role = msgRecord.role; - if (msgRecord.refusal !== undefined) sanitized.refusal = msgRecord.refusal; - - // Handle content — extract tags +function sanitizeMessageContent(msgRecord: JsonRecord, options: ParseOptions = {}): JsonRecord { if (typeof msgRecord.content === "string") { - const { content, thinking } = extractThinkingFromContent( - stripInternalToolEnvelopeText(msgRecord.content) - ); - sanitized.content = collapseExcessiveNewlines(content); - - // Set reasoning_content from prompt-format tags only when the provider did - // not also return a native OpenAI-compatible reasoning field. - if (thinking && !getReadableReasoningValue(msgRecord)) { - sanitized.reasoning_content = thinking; - } - } else if (msgRecord.content !== undefined) { - sanitized.content = msgRecord.content; + const strippedContent = stripInternalToolEnvelopeText(msgRecord.content); + const nativeReasoning = getReadableReasoningValue(msgRecord); + const { content, thinking } = + options.parseTextualReasoningTags === true && !nativeReasoning + ? extractThinkingFromContent(strippedContent) + : { content: strippedContent, thinking: null }; + const sanitized: JsonRecord = { content: collapseExcessiveNewlines(content) }; + if (thinking) sanitized.reasoning_content = thinking; + return sanitized; } - copyOpenAICompatibleReasoningFields(msgRecord, sanitized); + return msgRecord.content !== undefined ? { content: msgRecord.content } : {}; +} +function applyTextualToolCallSanitization(sanitized: JsonRecord, msgRecord: JsonRecord): void { const textualToolCall = parseTextualToolCallContent(sanitized.content); if (textualToolCall && !msgRecord.tool_calls) { sanitized.content = null; @@ -504,13 +531,26 @@ function sanitizeMessage(msg: unknown): unknown { } else if (containsTextualToolCallContent(sanitized.content) && !msgRecord.tool_calls) { sanitized.content = null; } +} + +function sanitizeMessage(msg: unknown, options: ParseOptions = {}): unknown { + const msgRecord = toRecord(msg); + if (!msgRecord) return msg; + + const sanitized: JsonRecord = {}; + + if (msgRecord.role) sanitized.role = msgRecord.role; + if (msgRecord.refusal !== undefined) sanitized.refusal = msgRecord.refusal; + + Object.assign(sanitized, sanitizeMessageContent(msgRecord, options)); + + copyOpenAICompatibleReasoningFields(msgRecord, sanitized); + applyTextualToolCallSanitization(sanitized, msgRecord); - // Preserve tool_calls if (msgRecord.tool_calls) { sanitized.tool_calls = msgRecord.tool_calls; } - // Preserve function_call (legacy) if (msgRecord.function_call) { sanitized.function_call = msgRecord.function_call; } diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index e2c49171327a..bbe844a3acf7 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -2,7 +2,7 @@ * Context Manager — Phase 4 * * Pre-flight context compression to prevent "prompt too long" errors. - * 3 layers: trim tool messages, compress thinking, aggressive purification. + * 3 layers: trim tool messages, compress structured thinking, aggressive purification. */ import { REGISTRY } from "../config/providerRegistry.ts"; @@ -147,7 +147,7 @@ export function resolveComboContextLimit(options: { * Operates in 3 layers of increasing aggressiveness: * * Layer 1: Trim tool_result messages (truncate long outputs) - * Layer 2: Compress thinking blocks (remove from history, keep last) + * Layer 2: Compress structured thinking blocks (remove from history, keep last) * Layer 3: Aggressive purification (drop old messages until fitting) * * @param {object} body - Request body with messages[] @@ -194,7 +194,7 @@ export function compressContext( }; } - // Layer 2: Compress thinking blocks (remove from non-last assistant messages) + // Layer 2: Compress structured thinking blocks (remove from non-last assistant messages) messages = compressThinking(messages); currentTokens = estimateTokens(JSON.stringify(messages)); stats.layers.push({ name: "compress_thinking", tokens: currentTokens }); @@ -249,7 +249,7 @@ function trimToolMessages(messages: Record[], maxChars: number) }); } -// ─── Layer 2: Compress Thinking Blocks ────────────────────────────────────── +// ─── Layer 2: Compress Structured Thinking Blocks ─────────────────────────── function compressThinking(messages: Record[]) { // Find last assistant message index @@ -274,28 +274,6 @@ function compressThinking(messages: Record[]) { return { ...msg, content: filtered }; } - // Remove thinking XML tags from string content - if (typeof msg.content === "string") { - let cleaned = msg.content; - for (const [start, end] of [ - ["", ""], - ["", ""], - ]) { - while (true) { - const s = cleaned.indexOf(start); - if (s === -1) break; - const e = cleaned.indexOf(end, s + start.length); - if (e === -1) { - cleaned = cleaned.slice(0, s); - break; - } - cleaned = cleaned.slice(0, s) + cleaned.slice(e + end.length); - } - } - cleaned = cleaned.trim(); - return { ...msg, content: cleaned || "[thinking compressed]" }; - } - return msg; }); } diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts index 0bb9156a73af..afe245a019a8 100644 --- a/open-sse/transformer/responsesTransformer.ts +++ b/open-sse/transformer/responsesTransformer.ts @@ -1,4 +1,5 @@ import { appendToolCallArgumentDelta } from "../utils/toolCallArguments.ts"; +import { shouldParseTextualReasoningTags } from "../handlers/responseSanitizer.ts"; import * as fs from "fs"; import * as path from "path"; /** @@ -92,6 +93,7 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv reasoningPartAdded: false, reasoningDone: false, inThinking: false, + parseTextualReasoningTags: false, funcArgsBuf: {}, funcNames: {}, funcCallIds: {}, @@ -407,6 +409,13 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv const choice = parsed.choices[0]; const idx = choice.index || 0; const delta = choice.delta || {}; + if (state.parseTextualReasoningTags !== true && typeof parsed.model === "string") { + state.parseTextualReasoningTags = shouldParseTextualReasoningTags( + undefined, + parsed.model + ); + } + const parseTextualReasoningTags = state.parseTextualReasoningTags === true; // Emit initial events if (!state.started) { @@ -443,41 +452,45 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv emitReasoningDelta(controller, delta.reasoning_content); } - // Handle text content (may contain tags) + // Handle text content. Generic prompt-format tags are visible text; + // only tag-native models opt into textual reasoning extraction. if (delta.content) { // Close reasoning if it was opened via native reasoning_content // and is still open, before emitting message content. Without this // the reasoning item is never closed and the message reuses the // reasoning output_index, producing a protocol-invalid stream. - // Guard on !inThinking: reasoning opened via tags is closed by - // its matching below — force-closing it here would snapshot a - // partial buffer (dense output records the item at close time). (#4848 + #4906) - if (state.reasoningId && !state.reasoningDone && !state.inThinking) { + if ( + state.reasoningId && + !state.reasoningDone && + (!parseTextualReasoningTags || !state.inThinking) + ) { closeReasoning(controller); } let content = delta.content; - if (content.includes("")) { - state.inThinking = true; - content = content.replaceAll("", ""); - startReasoning(controller, idx); - } + if (parseTextualReasoningTags) { + if (content.includes("")) { + state.inThinking = true; + content = content.replaceAll("", ""); + startReasoning(controller, idx); + } - if (content.includes("")) { - const parts = content.split(""); - const thinkPart = parts[0]; - const textPart = parts.slice(1).join(""); + if (content.includes("")) { + const parts = content.split(""); + const thinkPart = parts[0]; + const textPart = parts.slice(1).join(""); - if (thinkPart) emitReasoningDelta(controller, thinkPart); - closeReasoning(controller); - state.inThinking = false; - content = textPart; - } + if (thinkPart) emitReasoningDelta(controller, thinkPart); + closeReasoning(controller); + state.inThinking = false; + content = textPart; + } - if (state.inThinking && content) { - emitReasoningDelta(controller, content); - continue; + if (state.inThinking && content) { + emitReasoningDelta(controller, content); + continue; + } } // Regular text content diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 16b6fdf1a3a9..693374148991 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -289,8 +289,7 @@ export function translateRequest( // requested upstream; generic/implicit-cache OpenAI providers stay stripped. result = filterToOpenAIFormat(result, { preserveCacheControl: - options?.preserveCacheControl === true && - providerHonorsOpenAIFormatCacheControl(provider), + options?.preserveCacheControl === true && providerHonorsOpenAIFormatCacheControl(provider), // #4849 regression guard: keep client reasoning_content for replay providers. preserveReasoningContent: isReasoner, }); @@ -587,6 +586,7 @@ export function initState(sourceFormat) { reasoningPartAdded: false, reasoningDone: false, inThinking: false, + parseTextualReasoningTags: false, funcArgsBuf: {}, funcNames: {}, funcCallIds: {}, diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index ba23d688197c..5db0c4507fb8 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -297,6 +297,9 @@ export function geminiToOpenAIResponse(chunk, state) { const response = chunk.response || chunk; if (!response) return null; + const modelVersion = + typeof response.modelVersion === "string" ? response.modelVersion.toLowerCase() : ""; + const parseTextualReasoningTags = !chunk.response && !modelVersion.startsWith("antigravity/"); const results = []; const candidate = response.candidates?.[0]; @@ -438,16 +441,18 @@ export function geminiToOpenAIResponse(chunk, state) { } if (hasFunctionCall) { - // Flush any still-open textual reasoning wrapper as reasoning_content BEFORE - // the tool call. A signed native functionCall arriving while a `` - // (etc.) tag opened in an earlier chunk is still buffered must not silently - // drop that buffered reasoning — flushOpenTextualReasoning emits it and clears - // the active-tag/content buffers. (LEDGER-4 / #3821-review) - flushOpenTextualReasoning(state, results); - // Also drop any partial open-tag fragment buffered at a chunk boundary - // (flushOpenTextualReasoning early-returns when only this is set), matching the - // pre-fix branch which cleared all three buffers. (#3821-review convergence) - state.textualReasoningTagBuffer = undefined; + if (parseTextualReasoningTags) { + // Flush any still-open textual reasoning wrapper as reasoning_content BEFORE + // the tool call. A signed native functionCall arriving while a `` + // (etc.) tag opened in an earlier chunk is still buffered must not silently + // drop that buffered reasoning — flushOpenTextualReasoning emits it and clears + // the active-tag/content buffers. (LEDGER-4 / #3821-review) + flushOpenTextualReasoning(state, results); + // Also drop any partial open-tag fragment buffered at a chunk boundary + // (flushOpenTextualReasoning early-returns when only this is set), matching the + // pre-fix branch which cleared all three buffers. (#3821-review convergence) + state.textualReasoningTagBuffer = undefined; + } emitFunctionCallPart(part, state, results); } continue; @@ -459,7 +464,9 @@ export function geminiToOpenAIResponse(chunk, state) { // back to a structured OpenAI tool call so clients/tools do not see it as // assistant prose. if (part.text !== undefined && part.text !== "") { - const afterReasoning = consumeTextualReasoningTags(part.text, state, results); + const afterReasoning = parseTextualReasoningTags + ? consumeTextualReasoningTags(part.text, state, results) + : part.text; if (!afterReasoning) continue; let accumulated = (state.textualToolCallBuffer || "") + afterReasoning; @@ -677,7 +684,9 @@ export function geminiToOpenAIResponse(chunk, state) { // Finish reason - include usage in final chunk if (candidate.finishReason) { - flushOpenTextualReasoning(state, results); + if (parseTextualReasoningTags) { + flushOpenTextualReasoning(state, results); + } if (state.textualToolCallBuffer) { const remainingText = state.textualToolCallBuffer; diff --git a/open-sse/translator/response/kiro-to-openai.ts b/open-sse/translator/response/kiro-to-openai.ts index f58a2aaeaad8..694881ade8dc 100644 --- a/open-sse/translator/response/kiro-to-openai.ts +++ b/open-sse/translator/response/kiro-to-openai.ts @@ -92,7 +92,6 @@ export function convertKiroToOpenAI(chunk, state) { const content = data.reasoningContentEvent?.content || data.content || ""; if (!content) return null; - // Convert to thinking block format (Claude-style) const openaiChunk = { id: state.responseId, object: "chat.completion.chunk", @@ -103,7 +102,7 @@ export function convertKiroToOpenAI(chunk, state) { index: 0, delta: { ...(state.chunkIndex === 0 ? { role: "assistant" } : {}), - content: `${content}`, + reasoning_content: content, }, finish_reason: null, }, diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 90a6b74d8770..d6c397fbad6c 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -6,6 +6,7 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; import { fallbackToolCallId } from "../helpers/toolCallHelper.ts"; +import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts"; function normalizeToolName(value) { return typeof value === "string" ? value.trim() : ""; @@ -87,6 +88,10 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { const choice = chunk.choices[0]; const idx = choice.index || 0; const delta = choice.delta || {}; + if (state.parseTextualReasoningTags !== true && typeof chunk.model === "string") { + state.parseTextualReasoningTags = shouldParseTextualReasoningTags(undefined, chunk.model); + } + const parseTextualReasoningTags = state.parseTextualReasoningTags === true; // Emit initial events if (!state.started) { @@ -117,50 +122,45 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { }); } - // Handle reasoning_content if (delta.reasoning_content) { startReasoning(state, emit, idx); emitReasoningDelta(state, emit, delta.reasoning_content); } - - // Handle text content if (delta.content) { - // Close reasoning if it was opened via native reasoning_content and is - // still open, before emitting message content. Otherwise the reasoning - // item is never closed and the message reuses its output_index. - // Guard on !inThinking: reasoning opened via tags is closed by its - // matching below — force-closing it here would snapshot a partial - // buffer (dense output records the item at close time). (#4848 + #4906) - if (state.reasoningId && !state.reasoningDone && !state.inThinking) { + if ( + state.reasoningId && + !state.reasoningDone && + (!parseTextualReasoningTags || !state.inThinking) + ) { closeReasoning(state, emit); } let content = delta.content; - if (content.includes("")) { - state.inThinking = true; - content = content.replaceAll("", ""); - startReasoning(state, emit, idx); - } + if (parseTextualReasoningTags) { + if (content.includes("")) { + state.inThinking = true; + content = content.replaceAll("", ""); + startReasoning(state, emit, idx); + } - if (content.includes("")) { - const parts = content.split(""); - const thinkPart = parts[0]; - const textPart = parts.slice(1).join(""); - if (thinkPart) emitReasoningDelta(state, emit, thinkPart); - closeReasoning(state, emit); - state.inThinking = false; - content = textPart; - } + if (content.includes("")) { + const parts = content.split(""); + const thinkPart = parts[0]; + const textPart = parts.slice(1).join(""); + if (thinkPart) emitReasoningDelta(state, emit, thinkPart); + closeReasoning(state, emit); + state.inThinking = false; + content = textPart; + } - if (state.inThinking && content) { - emitReasoningDelta(state, emit, content); - return events; + if (state.inThinking && content) { + emitReasoningDelta(state, emit, content); + return events; + } } if (content) { - // Use a distinct output_index for the message when reasoning was - // emitted, so the message item does not collide with the reasoning item. const msgIdx = state.reasoningId ? state.reasoningIndex + 1 : idx; emitTextContent(state, emit, msgIdx, content); } diff --git a/tests/unit/context-manager.test.ts b/tests/unit/context-manager.test.ts index 7950930fa586..e16a16584e74 100644 --- a/tests/unit/context-manager.test.ts +++ b/tests/unit/context-manager.test.ts @@ -122,6 +122,38 @@ test("compressContext: Layer 2 — compresses thinking in old messages", () => { } }); +test("compressContext: Layer 2 preserves prompt-format thinking tags in string content", () => { + const body = { + model: "test", + messages: [ + { role: "user", content: "q1" }, + { + role: "assistant", + content: "visible prompt protocolanswer1", + }, + { role: "user", content: "q2" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "lots of structured thinking here ".repeat(500) }, + { type: "text", text: "answer2" }, + ], + }, + { role: "user", content: "q3" }, + { role: "assistant", content: "answer3" }, + ], + }; + const result = compressContext(body, { maxTokens: 2000, reserveTokens: 500 }); + const firstAssistant = (result.body as any).messages.find( + (m: any) => m.role === "assistant" && typeof m.content === "string" + ); + + assert.equal( + firstAssistant.content, + "visible prompt protocolanswer1" + ); +}); + test("compressContext: Layer 3 — drops old messages to fit", () => { const messages = [ { role: "system", content: "You are helpful" }, diff --git a/tests/unit/response-sanitizer.test.ts b/tests/unit/response-sanitizer.test.ts index 5458d9cb4d67..d46bc14f1d73 100644 --- a/tests/unit/response-sanitizer.test.ts +++ b/tests/unit/response-sanitizer.test.ts @@ -6,6 +6,7 @@ const { sanitizeOpenAIResponse, sanitizeResponsesApiResponse, sanitizeStreamingChunk, + shouldParseTextualReasoningTags, } = await import("../../open-sse/handlers/responseSanitizer.ts"); test("extractThinkingFromContent separates think blocks from visible content", () => { @@ -65,7 +66,7 @@ test("sanitizeOpenAIResponse strips non-standard fields and preserves required t }); }); -test("sanitizeOpenAIResponse extracts thinking, collapses newlines, preserves reasoning_content with tool_calls, and preserves tool calls", () => { +test("sanitizeOpenAIResponse preserves prompt-format thinking tags by default", () => { const sanitized = sanitizeOpenAIResponse({ id: "chatcmpl_test", model: "gpt-4.1", @@ -75,7 +76,7 @@ test("sanitizeOpenAIResponse extracts thinking, collapses newlines, preserves re finish_reason: "tool_calls", message: { role: "assistant", - content: "Hello\n\n\ninternal chain\n\nworld", + content: "Hello\n\n\nvisible protocol\n\nworld", tool_calls: [{ id: "call_1" }], function_call: { name: "legacy" }, }, @@ -85,44 +86,76 @@ test("sanitizeOpenAIResponse extracts thinking, collapses newlines, preserves re assert.equal((sanitized as any).choices[0].index, 2); assert.equal((sanitized as any).choices[0].finish_reason, "tool_calls"); - (assert as any).equal((sanitized as any).choices[0].message.content, "Hello\n\nworld"); - assert.equal((sanitized as any).choices[0].message.reasoning_content, "internal chain"); + (assert as any).equal( + (sanitized as any).choices[0].message.content, + "Hello\n\nvisible protocol\n\nworld" + ); + assert.equal((sanitized as any).choices[0].message.reasoning_content, undefined); (assert as any).deepEqual((sanitized as any).choices[0].message.tool_calls, [{ id: "call_1" }]); assert.deepEqual((sanitized as any).choices[0].message.function_call, { name: "legacy" }); }); -test("sanitizeOpenAIResponse extracts unclosed reasoning wrappers into reasoning_content", () => { - const sanitized = sanitizeOpenAIResponse({ - model: "gpt-4.1", - choices: [ - { - message: { - role: "assistant", - content: "§54§ { + const sanitized = sanitizeOpenAIResponse( + { + model: "deepseek-r1", + choices: [ + { + message: { + role: "assistant", + content: "Hello\n\n\ninternal chain\n\nworld", + }, }, - }, - ], - }); + ], + }, + { parseTextualReasoningTags: true } + ); - assert.equal((sanitized as any).choices[0].message.content, ""); - assert.equal((sanitized as any).choices[0].message.reasoning_content, "internal planning"); + assert.equal((sanitized as any).choices[0].message.content, "Hello\n\nworld"); + assert.equal((sanitized as any).choices[0].message.reasoning_content, "internal chain"); }); -test("sanitizeOpenAIResponse preserves native reasoning_content when no visible content remains", () => { - const sanitized = sanitizeOpenAIResponse({ - model: "gpt-4.1", - choices: [ - { - message: { - role: "assistant", - content: "discard me", - reasoning_content: "provider reasoning", +test("sanitizeOpenAIResponse extracts unclosed reasoning wrappers only when enabled", () => { + const sanitized = sanitizeOpenAIResponse( + { + model: "deepseek-r1", + choices: [ + { + message: { + role: "assistant", + content: "§54§ { + const sanitized = sanitizeOpenAIResponse( + { + model: "gpt-4.1", + choices: [ + { + message: { + role: "assistant", + content: "visible protocol", + reasoning_content: "provider reasoning", + }, + }, + ], + }, + { parseTextualReasoningTags: true } + ); + + assert.equal( + ((sanitized as any).choices[0].message as any).content, + "visible protocol" + ); assert.equal((sanitized as any).choices[0].message.reasoning_content, "provider reasoning"); }); @@ -246,7 +279,10 @@ test("sanitizeOpenAIResponse preserves OpenRouter native reasoning and signature assert.deepEqual((sanitized as any).choices[0].message.reasoning_details, [ { type: "reasoning.encrypted", data: "sig" }, ]); - assert.equal((sanitized as any).choices[0].message.content, "Visible answer"); + assert.equal( + (sanitized as any).choices[0].message.content, + "tag-derivedVisible answer" + ); }); test("sanitizeOpenAIResponse keeps reasoning_details-derived reasoning_content for reasoning-only messages", () => { @@ -618,6 +654,18 @@ test("sanitize functions return non-object inputs unchanged", () => { assert.equal(sanitizeStreamingChunk("raw text"), "raw text"); }); +test("shouldParseTextualReasoningTags is limited to tag-native model families", () => { + assert.equal(shouldParseTextualReasoningTags("together", "deepseek-ai/DeepSeek-R1"), true); + assert.equal(shouldParseTextualReasoningTags("cloudflare-ai", "@cf/qwen/qwq-32b"), true); + assert.equal(shouldParseTextualReasoningTags("openrouter", "deepseek/deepseek-v4-pro"), false); + assert.equal(shouldParseTextualReasoningTags("antigravity", "deepseek-r1"), false); + assert.equal(shouldParseTextualReasoningTags(undefined, "antigravity/deepseek-r1"), false); + assert.equal( + shouldParseTextualReasoningTags("openai-compatible-custom", "claude-opus-4.7"), + false + ); +}); + test("sanitizeOpenAIResponse converts textual pseudo tool-call content into structured tool_calls", () => { const sanitized = sanitizeOpenAIResponse({ id: "chatcmpl_textual_tool_call", diff --git a/tests/unit/responses-transformer.test.ts b/tests/unit/responses-transformer.test.ts index 9d2ab0ad244b..fef0226f549a 100644 --- a/tests/unit/responses-transformer.test.ts +++ b/tests/unit/responses-transformer.test.ts @@ -83,9 +83,30 @@ test("createResponsesApiTransformStream converts plain chat deltas into Response assert.equal(doneMarker.data, "[DONE]"); }); -test("createResponsesApiTransformStream converts think tags into reasoning summaries", async () => { +test("createResponsesApiTransformStream preserves prompt-format think tags by default", async () => { const output = await runTransformStream([ - 'data: {"choices":[{"index":0,"delta":{"content":"plan"}}]}\n\n', + 'data: {"id":"chatcmpl_1","model":"gpt-4.1","choices":[{"index":0,"delta":{"content":"plan"}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{"content":"ninganswer"}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', + ]); + + const events = parseSseOutput(output); + const reasoningDeltas = events + .filter((event) => event.event === "response.reasoning_summary_text.delta") + .map((event) => JSON.parse(event.data).delta); + const completed = JSON.parse( + events.find((event) => event.event === "response.completed").data + ).response; + + assert.deepEqual(reasoningDeltas, []); + assert.deepEqual(completed.output[0].content, [ + { type: "output_text", annotations: [], logprobs: [], text: "planninganswer" }, + ]); +}); + +test("createResponsesApiTransformStream extracts think tags for tag-native models", async () => { + const output = await runTransformStream([ + 'data: {"id":"chatcmpl_1","model":"deepseek-ai/DeepSeek-R1","choices":[{"index":0,"delta":{"content":"plan"}}]}\n\n', 'data: {"choices":[{"index":0,"delta":{"content":"ninganswer"}}]}\n\n', 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', ]); @@ -99,11 +120,7 @@ test("createResponsesApiTransformStream converts think tags into reasoning summa ).response; assert.deepEqual(reasoningDeltas, ["plan", "ning"]); - assert.deepEqual(completed.output[0], { - id: completed.output[0].id, - type: "reasoning", - summary: [{ type: "summary_text", text: "planning" }], - }); + assert.equal(completed.output[0].type, "reasoning"); assert.deepEqual(completed.output[1].content, [ { type: "output_text", annotations: [], logprobs: [], text: "answer" }, ]); diff --git a/tests/unit/responses-translation-fixes.test.ts b/tests/unit/responses-translation-fixes.test.ts index a155d39d6305..61cf4b23f1cf 100644 --- a/tests/unit/responses-translation-fixes.test.ts +++ b/tests/unit/responses-translation-fixes.test.ts @@ -510,7 +510,7 @@ test("Responses→Chat streaming: Copilot mode emits reasoning_text for summary assert.equal(result.choices[0].delta.reasoning, undefined); }); -test("Chat→Responses streaming: multiple tags in one chunk handled", () => { +test("Chat→Responses streaming: generic prompt-format tags remain text", () => { const state = initState(FORMATS.OPENAI_RESPONSES); // Chunk with multiple think tags @@ -523,14 +523,44 @@ test("Chat→Responses streaming: multiple tags in one chunk handled", ( }, ], id: "c1", + model: "gpt-4.1", }; const events = openaiToOpenAIResponsesResponse(chunk, state); - // Should not have literal in any text delta const textDeltas = events .filter((e) => e.event === "response.output_text.delta") .map((e) => e.data.delta); const combined = textDeltas.join(""); - assert.ok(!combined.includes(""), `text should not contain tag, got: ${combined}`); + assert.equal(combined, "firstmiddlesecondend"); + assert.equal( + events.some((e) => e.event === "response.reasoning_summary_text.delta"), + false + ); +}); + +test("Chat→Responses streaming: tag-native models still split tags", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + + const chunk = { + choices: [ + { + index: 0, + delta: { content: "firstend" }, + finish_reason: null, + }, + ], + id: "c1", + model: "deepseek-r1", + }; + const events = openaiToOpenAIResponsesResponse(chunk, state); + const textDeltas = events + .filter((e) => e.event === "response.output_text.delta") + .map((e) => e.data.delta); + const reasoningDeltas = events + .filter((e) => e.event === "response.reasoning_summary_text.delta") + .map((e) => e.data.delta); + + assert.deepEqual(reasoningDeltas, ["first"]); + assert.equal(textDeltas.join(""), "end"); }); // Regression: a tool call was announced (response.output_item.added set currentToolCallId) diff --git a/tests/unit/translator-resp-antigravity-thinking-boundary.test.ts b/tests/unit/translator-resp-antigravity-thinking-boundary.test.ts new file mode 100644 index 000000000000..395d1f3d6e80 --- /dev/null +++ b/tests/unit/translator-resp-antigravity-thinking-boundary.test.ts @@ -0,0 +1,75 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { geminiToOpenAIResponse } = + await import("../../open-sse/translator/response/gemini-to-openai.ts"); + +function createStreamingState() { + return { + toolCalls: new Map(), + }; +} + +test("Antigravity stream preserves prompt-format thinking tags as content", () => { + const state = createStreamingState(); + const first = geminiToOpenAIResponse( + { + response: { + responseId: "resp-ag-visible-thinking", + modelVersion: "antigravity/gemini-3-pro", + candidates: [{ content: { parts: [{ text: "\n[metacognition" }] } }], + }, + }, + state + ); + const second = geminiToOpenAIResponse( + { + response: { + responseId: "resp-ag-visible-thinking", + modelVersion: "antigravity/gemini-3-pro", + candidates: [{ content: { parts: [{ text: "]\n\nVisible answer" }] } }], + }, + }, + state + ); + + const deltas = [...first, ...second].map((event: any) => event.choices?.[0]?.delta || {}); + assert.deepEqual( + deltas.filter((delta: any) => delta.content).map((delta: any) => delta.content), + ["\n[metacognition", "]\n\nVisible answer"] + ); + assert.equal( + deltas.some((delta: any) => delta.reasoning_content !== undefined), + false + ); +}); + +test("Antigravity stream keeps native Gemini thought parts as reasoning_content", () => { + const result = geminiToOpenAIResponse( + { + response: { + responseId: "resp-ag-native-thought", + modelVersion: "antigravity/gemini-3-pro", + candidates: [ + { + content: { + parts: [{ thought: true, text: "Native plan" }, { text: "Visible answer" }], + }, + finishReason: "STOP", + }, + ], + }, + }, + createStreamingState() + ); + + assert.equal( + result.find((event: any) => event.choices?.[0]?.delta?.reasoning_content)?.choices[0].delta + .reasoning_content, + "Native plan" + ); + assert.equal( + result.find((event: any) => event.choices?.[0]?.delta?.content)?.choices[0].delta.content, + "Visible answer" + ); +}); diff --git a/tests/unit/translator-resp-kiro-to-openai.test.ts b/tests/unit/translator-resp-kiro-to-openai.test.ts index 38be7173e2ba..f99f2c666edb 100644 --- a/tests/unit/translator-resp-kiro-to-openai.test.ts +++ b/tests/unit/translator-resp-kiro-to-openai.test.ts @@ -26,13 +26,14 @@ test("Kiro -> OpenAI: subsequent assistantResponseEvent omits role", () => { assert.equal(result.choices[0].delta.content, "lo"); }); -test("Kiro -> OpenAI: reasoningContentEvent is wrapped as thinking tags", () => { +test("Kiro -> OpenAI: reasoningContentEvent emits native reasoning_content", () => { const result = convertKiroToOpenAI( 'event:reasoningContentEvent\ndata:{"content":"Need to inspect first"}\n\n', {} ); - assert.equal(result.choices[0].delta.content, "Need to inspect first"); + assert.equal(result.choices[0].delta.reasoning_content, "Need to inspect first"); + assert.equal(result.choices[0].delta.content, undefined); }); test("Kiro -> OpenAI: toolUseEvent becomes OpenAI tool_calls", () => { diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 3401fbb6844f..40dd6a05985a 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -112,7 +112,7 @@ test("OpenAI -> Responses: flush on null closes text content and emits response. assert.ok(events.some((event) => event.event === "response.completed")); }); -test("OpenAI -> Responses: tags become reasoning events and normal text still streams", () => { +test("OpenAI -> Responses: prompt-format tags remain text by default", () => { const events = collectEvents([ { id: "chatcmpl-3", @@ -127,6 +127,34 @@ test("OpenAI -> Responses: tags become reasoning events and normal text }, ]); + assert.equal( + events.some((event) => event.event === "response.reasoning_summary_text.delta"), + false + ); + assert.ok( + events.some( + (event) => + event.event === "response.output_text.delta" && + event.data.delta === "Plan itDone." + ) + ); +}); + +test("OpenAI -> Responses: tag-native models still emit text as reasoning", () => { + const events = collectEvents([ + { + id: "chatcmpl-3b", + model: "Qwen/QwQ-32B", + choices: [ + { + index: 0, + delta: { content: "Plan itDone." }, + finish_reason: "stop", + }, + ], + }, + ]); + assert.ok( events.some( (event) => From 3ab9723a7b12293eb844d3cef0e87df272a8a545 Mon Sep 17 00:00:00 2001 From: backryun Date: Mon, 29 Jun 2026 00:55:42 +0900 Subject: [PATCH 06/61] Add more Icons (#5220) Add lobe provider icons + aliases; rebased onto release tip. Integrated into release/v3.8.40. --- src/shared/components/lobeProviderIcons.ts | 32 +++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/shared/components/lobeProviderIcons.ts b/src/shared/components/lobeProviderIcons.ts index 973bb5caab08..2739355a3090 100644 --- a/src/shared/components/lobeProviderIcons.ts +++ b/src/shared/components/lobeProviderIcons.ts @@ -30,6 +30,8 @@ import BedrockMonoIcon from "@lobehub/icons/es/Bedrock/components/Mono"; import BflMonoIcon from "@lobehub/icons/es/Bfl/components/Mono"; import CerebrasColorIcon from "@lobehub/icons/es/Cerebras/components/Color"; import CerebrasMonoIcon from "@lobehub/icons/es/Cerebras/components/Mono"; +import ClaudeColorIcon from "@lobehub/icons/es/Claude/components/Color"; +import ClaudeMonoIcon from "@lobehub/icons/es/Claude/components/Mono"; import ClaudeCodeColorIcon from "@lobehub/icons/es/ClaudeCode/components/Color"; import ClaudeCodeMonoIcon from "@lobehub/icons/es/ClaudeCode/components/Mono"; import ClineMonoIcon from "@lobehub/icons/es/Cline/components/Mono"; @@ -41,6 +43,8 @@ import CohereColorIcon from "@lobehub/icons/es/Cohere/components/Color"; import CohereMonoIcon from "@lobehub/icons/es/Cohere/components/Mono"; import ComfyUIColorIcon from "@lobehub/icons/es/ComfyUI/components/Color"; import ComfyUIMonoIcon from "@lobehub/icons/es/ComfyUI/components/Mono"; +import CopilotColorIcon from "@lobehub/icons/es/Copilot/components/Color"; +import CopilotMonoIcon from "@lobehub/icons/es/Copilot/components/Mono"; import CursorMonoIcon from "@lobehub/icons/es/Cursor/components/Mono"; import DbrxColorIcon from "@lobehub/icons/es/Dbrx/components/Color"; import CozeMonoIcon from "@lobehub/icons/es/Coze/components/Mono"; @@ -49,6 +53,7 @@ import DeepInfraColorIcon from "@lobehub/icons/es/DeepInfra/components/Color"; import DeepInfraMonoIcon from "@lobehub/icons/es/DeepInfra/components/Mono"; import DeepSeekColorIcon from "@lobehub/icons/es/DeepSeek/components/Color"; import DeepSeekMonoIcon from "@lobehub/icons/es/DeepSeek/components/Mono"; +import DevinColorIcon from "@lobehub/icons/es/Devin/components/Color"; import DifyColorIcon from "@lobehub/icons/es/Dify/components/Color"; import DifyMonoIcon from "@lobehub/icons/es/Dify/components/Mono"; import DoubaoColorIcon from "@lobehub/icons/es/Doubao/components/Color"; @@ -67,6 +72,7 @@ import GeminiColorIcon from "@lobehub/icons/es/Gemini/components/Color"; import GeminiMonoIcon from "@lobehub/icons/es/Gemini/components/Mono"; import GeminiCLIColorIcon from "@lobehub/icons/es/GeminiCLI/components/Color"; import GeminiCLIMonoIcon from "@lobehub/icons/es/GeminiCLI/components/Mono"; +import GithubMonoIcon from "@lobehub/icons/es/Github/components/Mono"; import GithubCopilotMonoIcon from "@lobehub/icons/es/GithubCopilot/components/Mono"; import GoogleColorIcon from "@lobehub/icons/es/Google/components/Color"; import GoogleMonoIcon from "@lobehub/icons/es/Google/components/Mono"; @@ -139,6 +145,7 @@ import StabilityColorIcon from "@lobehub/icons/es/Stability/components/Color"; import StabilityMonoIcon from "@lobehub/icons/es/Stability/components/Mono"; import StepfunColorIcon from "@lobehub/icons/es/Stepfun/components/Color"; import StepfunMonoIcon from "@lobehub/icons/es/Stepfun/components/Mono"; +import SunoMonoIcon from "@lobehub/icons/es/Suno/components/Mono"; import TavilyColorIcon from "@lobehub/icons/es/Tavily/components/Color"; import TavilyMonoIcon from "@lobehub/icons/es/Tavily/components/Mono"; import TogetherColorIcon from "@lobehub/icons/es/Together/components/Color"; @@ -148,6 +155,8 @@ import TogetherMonoIcon from "@lobehub/icons/es/Together/components/Mono"; import TopazLabsMonoIcon from "@lobehub/icons/es/TopazLabs/components/Mono"; import TraeColorIcon from "@lobehub/icons/es/Trae/components/Color"; import TraeMonoIcon from "@lobehub/icons/es/Trae/components/Mono"; +import UdioColorIcon from "@lobehub/icons/es/Udio/components/Color"; +import UdioMonoIcon from "@lobehub/icons/es/Udio/components/Mono"; import UpstageColorIcon from "@lobehub/icons/es/Upstage/components/Color"; import UpstageMonoIcon from "@lobehub/icons/es/Upstage/components/Mono"; import YiColorIcon from "@lobehub/icons/es/Yi/components/Color"; @@ -205,17 +214,20 @@ const LOBE_ICON_COMPONENTS = { Bedrock: { mono: BedrockMonoIcon, color: BedrockColorIcon }, Bfl: { mono: BflMonoIcon }, Cerebras: { mono: CerebrasMonoIcon, color: CerebrasColorIcon }, + Claude: { mono: ClaudeMonoIcon, color: ClaudeColorIcon }, ClaudeCode: { mono: ClaudeCodeMonoIcon, color: ClaudeCodeColorIcon }, Cline: { mono: ClineMonoIcon }, Cloudflare: { mono: CloudflareMonoIcon, color: CloudflareColorIcon }, Codex: { mono: CodexMonoIcon, color: CodexColorIcon }, Cohere: { mono: CohereMonoIcon, color: CohereColorIcon }, ComfyUI: { mono: ComfyUIMonoIcon, color: ComfyUIColorIcon }, + Copilot: { mono: CopilotMonoIcon, color: CopilotColorIcon }, Coze: { mono: CozeMonoIcon }, Cursor: { mono: CursorMonoIcon }, Dbrx: { mono: DbrxMonoIcon, color: DbrxColorIcon }, DeepInfra: { mono: DeepInfraMonoIcon, color: DeepInfraColorIcon }, DeepSeek: { mono: DeepSeekMonoIcon, color: DeepSeekColorIcon }, + Devin: { mono: DevinColorIcon, color: DevinColorIcon }, Dify: { mono: DifyMonoIcon, color: DifyColorIcon }, Doubao: { mono: DoubaoMonoIcon, color: DoubaoColorIcon }, ElevenLabs: { mono: ElevenLabsMonoIcon }, @@ -226,6 +238,7 @@ const LOBE_ICON_COMPONENTS = { Friendli: { mono: FriendliMonoIcon }, Gemini: { mono: GeminiMonoIcon, color: GeminiColorIcon }, GeminiCLI: { mono: GeminiCLIMonoIcon, color: GeminiCLIColorIcon }, + Github: { mono: GithubMonoIcon }, GithubCopilot: { mono: GithubCopilotMonoIcon }, Google: { mono: GoogleMonoIcon, color: GoogleColorIcon }, Grok: { mono: GrokMonoIcon }, @@ -274,11 +287,13 @@ const LOBE_ICON_COMPONENTS = { Snowflake: { mono: SnowflakeMonoIcon, color: SnowflakeColorIcon }, Stability: { mono: StabilityMonoIcon, color: StabilityColorIcon }, Stepfun: { mono: StepfunMonoIcon, color: StepfunColorIcon }, + Suno: { mono: SunoMonoIcon }, Tavily: { mono: TavilyMonoIcon, color: TavilyColorIcon }, Tencent: { mono: TencentMonoIcon, color: TencentColorIcon }, Together: { mono: TogetherMonoIcon, color: TogetherColorIcon }, TopazLabs: { mono: TopazLabsMonoIcon }, Trae: { mono: TraeMonoIcon, color: TraeColorIcon }, + Udio: { mono: UdioMonoIcon, color: UdioColorIcon }, Upstage: { mono: UpstageMonoIcon, color: UpstageColorIcon }, V0: { mono: V0MonoIcon }, Venice: { mono: VeniceMonoIcon, color: VeniceColorIcon }, @@ -319,7 +334,7 @@ const LOBE_PROVIDER_ALIASES = { cerebras: "Cerebras", "chatgpt-web": "OpenAI", claude: "ClaudeCode", - "claude-web": "HuggingFace", + "claude-web": "Claude", cline: "Cline", cloudflare: "Cloudflare", "cloudflare-ai": "WorkersAI", @@ -329,6 +344,7 @@ const LOBE_PROVIDER_ALIASES = { cohere: "Cohere", comfyui: "ComfyUI", copilot: "GithubCopilot", + "copilot-web": "Copilot", coze: "Coze", cursor: "Cursor", "cursor-cloud": "Cursor", @@ -336,6 +352,10 @@ const LOBE_PROVIDER_ALIASES = { deepinfra: "DeepInfra", deepseek: "DeepSeek", "deepseek-web": "DeepSeek", + devin: "Devin", + "devin-cli": "Devin", + doubao: "Doubao", + "doubao-web": "Doubao", elevenlabs: "ElevenLabs", exa: "Exa", "exa-search": "Exa", @@ -348,8 +368,11 @@ const LOBE_PROVIDER_ALIASES = { friendli: "Friendli", friendliai: "Friendli", gemini: "Gemini", + "gemini-web": "Gemini", + "gemini-business": "Gemini", "gemini-cli": "GeminiCLI", github: "GithubCopilot", + "github-models": "Github", "github-copilot": "GithubCopilot", glm: "Zhipu", "glm-cn": "Zhipu", @@ -357,7 +380,9 @@ const LOBE_PROVIDER_ALIASES = { "google-pse-search": "Google", grok: "Grok", "grok-web": "Grok", + "grok-cli": "Grok", groq: "Groq", + huggingchat: "HuggingFace", "hugging-face": "HuggingFace", huggingface: "HuggingFace", hyperbolic: "Hyperbolic", @@ -367,6 +392,7 @@ const LOBE_PROVIDER_ALIASES = { "jina-ai": "Jina", kilocode: "KiloCode", kimi: "Kimi", + "kimi-web": "Kimi", "kimi-coding": "Kimi", "kimi-coding-apikey": "Kimi", lambda: "Lambda", @@ -378,6 +404,7 @@ const LOBE_PROVIDER_ALIASES = { "meta-llama": "Meta", minimax: "Minimax", "minimax-cn": "Minimax", + mimocode: "XiaomiMiMo", mistral: "Mistral", mistralai: "Mistral", moonshot: "Moonshot", @@ -407,6 +434,7 @@ const LOBE_PROVIDER_ALIASES = { pollinations: "Pollinations", qoder: "Qoder", qwen: "Qwen", + "qwen-web": "Qwen", recraft: "Recraft", replicate: "Replicate", roo: "RooCode", @@ -420,6 +448,7 @@ const LOBE_PROVIDER_ALIASES = { stepfun: "Stepfun", stability: "Stability", "stability-ai": "Stability", + suno: "Suno", tavily: "Tavily", "tavily-search": "Tavily", tencent: "Tencent", @@ -428,6 +457,7 @@ const LOBE_PROVIDER_ALIASES = { topaz: "TopazLabs", trae: "Trae", triton: "Nvidia", + udio: "Udio", upstage: "Upstage", v0: "V0", "v0-vercel": "V0", From 3ee53cd56138d2893ef6413aaaf5ad91f13dc71e Mon Sep 17 00:00:00 2001 From: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:58:50 -0700 Subject: [PATCH 07/61] fix(mcp): forward HTTP auth to internal tool fetches (#5218) Forward MCP HTTP auth to internal tool fetches via AsyncLocalStorage (#5211). Rebased onto release tip. Integrated into release/v3.8.40. --- .../__tests__/httpAuthContext.test.ts | 143 ++++++++++++++++++ open-sse/mcp-server/httpAuthContext.ts | 42 +++++ open-sse/mcp-server/httpTransport.ts | 11 +- open-sse/mcp-server/server.ts | 69 ++++----- open-sse/mcp-server/tools/advancedTools.ts | 4 +- 5 files changed, 225 insertions(+), 44 deletions(-) create mode 100644 open-sse/mcp-server/__tests__/httpAuthContext.test.ts create mode 100644 open-sse/mcp-server/httpAuthContext.ts diff --git a/open-sse/mcp-server/__tests__/httpAuthContext.test.ts b/open-sse/mcp-server/__tests__/httpAuthContext.test.ts new file mode 100644 index 000000000000..b90a7b30d6c6 --- /dev/null +++ b/open-sse/mcp-server/__tests__/httpAuthContext.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; + +import { createMcpServer } from "../server.ts"; +import { + getMcpHttpAuthHeadersForInternalFetch, + withMcpHttpAuthContext, +} from "../httpAuthContext.ts"; + +vi.mock("../audit.ts", () => ({ + logToolCall: vi.fn().mockResolvedValue(undefined), +})); + +describe("MCP HTTP auth context", () => { + it("forwards bearer and cookie credentials to in-process internal fetches", async () => { + const request = new Request("http://localhost/api/mcp/stream", { + headers: { + Authorization: "Bearer manage-key", + Cookie: "auth_token=session-token", + }, + }); + + const headers = await withMcpHttpAuthContext(request, async () => + getMcpHttpAuthHeadersForInternalFetch() + ); + + expect(headers).toEqual({ + Authorization: "Bearer manage-key", + Cookie: "auth_token=session-token", + }); + }); + + it("forwards Anthropic-style x-api-key only with its contract header", async () => { + const request = new Request("http://localhost/api/mcp/stream", { + headers: { + "x-api-key": "manage-key", + "anthropic-version": "2023-06-01", + }, + }); + + const headers = await withMcpHttpAuthContext(request, async () => + getMcpHttpAuthHeadersForInternalFetch() + ); + + expect(headers).toEqual({ + "x-api-key": "manage-key", + "anthropic-version": "2023-06-01", + }); + }); + + it("does not forward bare x-api-key without anthropic-version", async () => { + const request = new Request("http://localhost/api/mcp/stream", { + headers: { "x-api-key": "placeholder" }, + }); + + const headers = await withMcpHttpAuthContext(request, async () => + getMcpHttpAuthHeadersForInternalFetch() + ); + + expect(headers).toEqual({}); + }); + + it("does not leak auth context outside the wrapped request", async () => { + const request = new Request("http://localhost/api/mcp/stream", { + headers: { Authorization: "Bearer manage-key" }, + }); + + await withMcpHttpAuthContext(request, async () => { + expect(getMcpHttpAuthHeadersForInternalFetch()).toEqual({ + Authorization: "Bearer manage-key", + }); + }); + + expect(getMcpHttpAuthHeadersForInternalFetch()).toEqual({}); + }); + + it("forwards request auth through registered core tools", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ combos: [] }), + }); + vi.stubGlobal("fetch", fetchMock); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverTransport); + const client = new Client({ name: "test-client", version: "1.0.0" }); + await client.connect(clientTransport); + + try { + const request = new Request("http://localhost/api/mcp/stream", { + headers: { Authorization: "Bearer manage-key" }, + }); + await withMcpHttpAuthContext(request, () => + client.callTool({ name: "omniroute_list_combos", arguments: {} }) + ); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/combos"), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer manage-key" }), + }) + ); + } finally { + await client.close(); + vi.unstubAllGlobals(); + } + }); + + it("forwards request auth through advanced tool apiFetch", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ entries: 2, hitRate: 0.5 }), + }); + vi.stubGlobal("fetch", fetchMock); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverTransport); + const client = new Client({ name: "test-client", version: "1.0.0" }); + await client.connect(clientTransport); + + try { + const request = new Request("http://localhost/api/mcp/stream", { + headers: { Authorization: "Bearer manage-key" }, + }); + await withMcpHttpAuthContext(request, () => + client.callTool({ name: "omniroute_cache_stats", arguments: {} }) + ); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/cache"), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer manage-key" }), + }) + ); + } finally { + await client.close(); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/open-sse/mcp-server/httpAuthContext.ts b/open-sse/mcp-server/httpAuthContext.ts new file mode 100644 index 000000000000..a070f19e8895 --- /dev/null +++ b/open-sse/mcp-server/httpAuthContext.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +type McpHttpAuthContext = { + authorization?: string; + cookie?: string; + xApiKey?: string; + anthropicVersion?: string; +}; + +const mcpHttpAuthContext = new AsyncLocalStorage(); + +function headerValue(request: Request, name: string): string | undefined { + const value = request.headers.get(name); + return value && value.trim().length > 0 ? value : undefined; +} + +export function getMcpHttpAuthHeadersForInternalFetch(): Record { + const context = mcpHttpAuthContext.getStore(); + const headers: Record = {}; + if (context?.authorization) headers.Authorization = context.authorization; + if (context?.cookie) headers.Cookie = context.cookie; + if (context?.xApiKey && context?.anthropicVersion) { + headers["x-api-key"] = context.xApiKey; + headers["anthropic-version"] = context.anthropicVersion; + } + return headers; +} + +export async function withMcpHttpAuthContext( + request: Request, + callback: () => Promise +): Promise { + return mcpHttpAuthContext.run( + { + authorization: headerValue(request, "authorization"), + cookie: headerValue(request, "cookie"), + xApiKey: headerValue(request, "x-api-key"), + anthropicVersion: headerValue(request, "anthropic-version"), + }, + callback + ); +} diff --git a/open-sse/mcp-server/httpTransport.ts b/open-sse/mcp-server/httpTransport.ts index 1cee592e6afd..67448e15d0db 100644 --- a/open-sse/mcp-server/httpTransport.ts +++ b/open-sse/mcp-server/httpTransport.ts @@ -11,6 +11,7 @@ import { randomUUID } from "node:crypto"; import { createMcpServer } from "./server.ts"; +import { withMcpHttpAuthContext } from "./httpAuthContext.ts"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -177,7 +178,9 @@ async function handleStreamableRequest(request: Request): Promise { try { session.lastActivityAt = Date.now(); - const response = await session.transport.handleRequest(request); + const response = await withMcpHttpAuthContext(request, () => + session.transport.handleRequest(request) + ); if (request.method === "DELETE") { closeStreamableSession(sessionId); } @@ -201,7 +204,9 @@ async function handleStreamableRequest(request: Request): Promise { const session = createStreamableSession(); try { - const response = await session.transport.handleRequest(request); + const response = await withMcpHttpAuthContext(request, () => + session.transport.handleRequest(request) + ); return withSessionHeader(response, session.sessionId); } catch (err) { closeStreamableSession(session.sessionId); @@ -230,7 +235,7 @@ export async function handleMcpSSE(request: Request): Promise { const { transport } = ensureSseServer(); try { - return await transport.handleRequest(request); + return await withMcpHttpAuthContext(request, () => transport.handleRequest(request)); } catch (err) { console.error("[MCP] SSE error:", err); return new Response(JSON.stringify({ error: "MCP SSE transport error" }), { diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 85eb0182e07e..49f0a02d7abd 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -1,15 +1,3 @@ -/** - * OmniRoute MCP Server — Model Context Protocol server exposing - * OmniRoute gateway intelligence as tools for AI agents. - * - * Supports two transports: - * 1. stdio — for IDE integration (VS Code, Cursor, Claude Desktop) - * 2. HTTP — for remote/programmatic access - * - * Tools wrap existing OmniRoute API endpoints and add intelligence - * such as routing simulation, budget guards, and session snapshots. - */ - import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { @@ -56,6 +44,7 @@ import { resolveCallerScopeContext, type McpToolExtraLike, } from "./scopeEnforcement.ts"; +import { getMcpHttpAuthHeadersForInternalFetch } from "./httpAuthContext.ts"; import { handleSimulateRoute, @@ -101,8 +90,6 @@ import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts"; import { AI_PROVIDERS, NOAUTH_PROVIDERS } from "../../src/shared/constants/providers.ts"; import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; -// ============ Configuration ============ - const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl(); const MCP_ENFORCE_SCOPES = process.env.OMNIROUTE_MCP_ENFORCE_SCOPES === "true"; const MCP_ALLOWED_SCOPES = new Set( @@ -217,9 +204,6 @@ function normalizeComboModels( }); } -/** - * Internal fetch helper that calls OmniRoute API endpoints. - */ function getOmniRouteApiKey(): string { return process.env.OMNIROUTE_API_KEY || ""; } @@ -229,6 +213,7 @@ async function omniRouteFetch(path: string, options: RequestInit = {}): Promise< const apiKey = getOmniRouteApiKey(); const headers: Record = { "Content-Type": "application/json", + ...getMcpHttpAuthHeadersForInternalFetch(), ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), ...((options.headers as Record) || {}), }; @@ -298,9 +283,17 @@ function getCatalogModelCapabilities(model: JsonRecord): string[] { return ["chat"]; } -function normalizeCatalogStatus(model: JsonRecord, source: string, warning?: string): McpCatalogStatus { +function normalizeCatalogStatus( + model: JsonRecord, + source: string, + warning?: string +): McpCatalogStatus { const explicitStatus = toString(model.status); - if (explicitStatus === "available" || explicitStatus === "degraded" || explicitStatus === "unavailable") { + if ( + explicitStatus === "available" || + explicitStatus === "degraded" || + explicitStatus === "unavailable" + ) { return explicitStatus; } @@ -358,7 +351,8 @@ export async function getMcpModelsCatalog( connections = Array.isArray(connections) ? connections : []; const activeConnections = connections.filter((connection) => { - const provider = typeof connection?.provider === "string" ? normalizeProviderId(connection.provider) : null; + const provider = + typeof connection?.provider === "string" ? normalizeProviderId(connection.provider) : null; if (!provider || !connection?.id || connection.isActive === false) return false; if (requestedProvider && provider !== requestedProvider) return false; return true; @@ -371,7 +365,9 @@ export async function getMcpModelsCatalog( })); if (requestedProvider && requestSpecs.length === 0) { - const isNoAuthProvider = Object.values(NOAUTH_PROVIDERS).some((provider) => provider.id === requestedProvider); + const isNoAuthProvider = Object.values(NOAUTH_PROVIDERS).some( + (provider) => provider.id === requestedProvider + ); if (isNoAuthProvider) { requestSpecs.push({ provider: requestedProvider, @@ -393,7 +389,10 @@ export async function getMcpModelsCatalog( for (const spec of requestSpecs) { const raw = toRecord(await fetchJson(spec.path)); - const source = toString(raw.source, spec.path.startsWith("/api/providers/") ? "api" : "v1_catalog"); + const source = toString( + raw.source, + spec.path.startsWith("/api/providers/") ? "api" : "v1_catalog" + ); const warning = raw.warning ? String(raw.warning) : undefined; if (warning) warnings.add(warning); sources.add(source); @@ -433,7 +432,12 @@ function withScopeEnforcement( ) { return async (args: unknown, extra?: McpToolExtraLike): Promise => { const scopeContext = resolveCallerScopeContext(extra, Array.from(MCP_ALLOWED_SCOPES)); - const scopeCheck = evaluateToolScopes(toolName, scopeContext.scopes, MCP_ENFORCE_SCOPES, toolScopes); + const scopeCheck = evaluateToolScopes( + toolName, + scopeContext.scopes, + MCP_ENFORCE_SCOPES, + toolScopes + ); if (!scopeCheck.allowed) { const missingScopes = scopeCheck.missing.length > 0 ? scopeCheck.missing.join(", ") : "unavailable"; @@ -470,8 +474,6 @@ function withScopeEnforcement( }; } -// ============ Tool Handlers ============ - async function handleGetHealth() { const start = Date.now(); try { @@ -822,11 +824,6 @@ async function handleWebFetch(args: { } } -// ============ MCP Server Setup ============ - -/** - * Create and configure the OmniRoute MCP Server with all essential tools. - */ export function createMcpServer(): McpServer { const server = new McpServer({ name: "omniroute", @@ -898,7 +895,6 @@ export function createMcpServer(): McpServer { ...notionTools.map((t) => t.name), ]); - // Register essential tools server.registerTool( "omniroute_get_health", { @@ -990,8 +986,6 @@ export function createMcpServer(): McpServer { ) ); - // ── Advanced Tools (Phase 3) ────────────────────────────── - server.registerTool( "omniroute_simulate_route", { @@ -1143,9 +1137,7 @@ export function createMcpServer(): McpServer { "Fetches and extracts content from a URL using OmniRoute's web fetch gateway. Supports multiple providers (Firecrawl, Jina Reader, Tavily) with automatic failover. Returns the page content as markdown, HTML, links, or screenshot, along with metadata.", inputSchema: webFetchInput, }, - withScopeEnforcement("omniroute_web_fetch", (args) => - handleWebFetch(webFetchInput.parse(args)) - ) + withScopeEnforcement("omniroute_web_fetch", (args) => handleWebFetch(webFetchInput.parse(args))) ); server.registerTool( @@ -1170,8 +1162,6 @@ export function createMcpServer(): McpServer { ) ); - // ── 1proxy Tools ────────────────────────────── - server.registerTool( "omniroute_oneproxy_fetch", { @@ -1459,7 +1449,8 @@ export function createMcpServer(): McpServer { }); // ── Dynamic Skill Tools (from skills table) ── - const skillToMcpToolName = (skill: { name: string }) => `skill_${skill.name.replace(/[^a-z0-9_-]/gi, "_")}`; + const skillToMcpToolName = (skill: { name: string }) => + `skill_${skill.name.replace(/[^a-z0-9_-]/gi, "_")}`; try { const enabledSkills = skillRegistry.list().filter((s) => s.enabled); for (const skill of enabledSkills) { diff --git a/open-sse/mcp-server/tools/advancedTools.ts b/open-sse/mcp-server/tools/advancedTools.ts index 86dc3930ed2b..0efa428126e2 100644 --- a/open-sse/mcp-server/tools/advancedTools.ts +++ b/open-sse/mcp-server/tools/advancedTools.ts @@ -14,11 +14,10 @@ * 9. omniroute_get_session_snapshot — Full session state snapshot * 10. omniroute_db_health_check — Diagnose and repair DB state drift * 11. omniroute_sync_pricing — Sync provider pricing from external source - * 12. omniroute_cache_stats — Cache statistics and hit rates - * 13. omniroute_cache_flush — Flush/invalidate cache entries */ import { logToolCall } from "../audit.ts"; +import { getMcpHttpAuthHeadersForInternalFetch } from "../httpAuthContext.ts"; import { normalizeQuotaResponse } from "../../../src/shared/contracts/quota.ts"; import { resolveOmniRouteBaseUrl } from "../../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; import { @@ -39,6 +38,7 @@ async function apiFetch(path: string, options: RequestInit = {}): Promise = { "Content-Type": "application/json", + ...getMcpHttpAuthHeadersForInternalFetch(), ...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}), ...((options.headers as Record) || {}), }; From 6143ffc021d8c9d9044952dfc6134deb3eaf80db Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:03:08 -0300 Subject: [PATCH 08/61] fix(cli): ship scripts/build/runtime-env.mjs in npm package (#5227) (#5230) serve.mjs imports scripts/build/runtime-env.mjs (added with the #5213 heap auto-calibration fix) but it was missing from package.json files whitelist, breaking every global npm install at startup. Add it to files and guard with a regression test that asserts all bin/ runtime imports of scripts/ are packaged. --- CHANGELOG.md | 3 +- package.json | 1 + scripts/build/pack-artifact-policy.ts | 3 + .../cli-runtime-imports-packaged-5227.test.ts | 86 +++++++++++++++++++ tests/unit/pack-artifact-policy.test.ts | 1 + 5 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/unit/cli-runtime-imports-packaged-5227.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f013f9923bc..82ed6a04d1a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ _In development — bullets added per PR; finalized at release._ ### 🔧 Bug Fixes +- **fix(cli): global npm install no longer fails with "Cannot find module scripts/build/runtime-env.mjs"** — the v3.8.39 heap auto-calibration fix made `bin/cli/commands/serve.mjs` import `scripts/build/runtime-env.mjs`, but that file was never added to the `files` whitelist in `package.json`, so the published npm tarball shipped the importing CLI without the imported module — breaking **every** `npm install -g omniroute` at startup (regression, all platforms). The file is now whitelisted (`npm pack` confirms it ships) and added to the pack-artifact policy allow/required lists, and a new regression test scans all `bin/**` entrypoints for runtime imports resolving under `scripts/` and asserts each is covered by the package `files` whitelist, so any future unpackaged CLI import fails CI instead of users' installs. ([#5227](https://github.com/diegosouzapw/OmniRoute/issues/5227) — thanks @PriyomSaha, @m-Yaghoubi, @jonlwheat2-gif) - **fix(oauth): Antigravity refresh no longer nulls the stored refresh_token on an empty upstream response** — Google's OAuth token endpoint uses non-rotating refresh tokens: a refresh response normally OMITS `refresh_token` and occasionally returns it as an empty string. The Antigravity executor's `refreshCredentials` used `typeof tokens.refresh_token === "string" ? tokens.refresh_token : credentials.refreshToken`, and because `typeof "" === "string"` is true, an empty-string response overwrote the good token with `""` — nulling it on first refresh. The check now treats a non-string **or empty** value as absent and preserves the stored token, matching the canonical `refreshGoogleToken` (`tokens.refresh_token || refreshToken`) semantics. ([#3850](https://github.com/diegosouzapw/OmniRoute/issues/3850) — thanks @3xa228148) - **fix(api): LAN/Tailscale dashboard access — `ws:` CSP scheme, GET-exempt version route, surface combo field errors** — three failures when opening the dashboard from a non-loopback host: (1) CSP `connect-src` allowed the `ws:` scheme only for loopback origins, blocking the dashboard's `ws://:*` Live WebSocket from LAN/Tailscale clients; the bare `ws:` scheme is now permitted (symmetric with the bare `wss:` already allowed), kept declarative in `next.config.mjs` with no global middleware (the project has none by design); (2) `GET /api/system/version` was blocked by `LOCAL_ONLY_API_PREFIXES` for all methods despite only `POST` spawning child processes (git/npm/pm2) — a new `LOCAL_ONLY_API_GET_EXEMPTIONS` set exempts safe read methods for this path while keeping `POST`/`PUT`/`PATCH`/`DELETE` strictly loopback-only; (3) `COMBO_002` validation errors only surfaced the generic message — `firstField`/`firstMessage` are now extracted from the first Zod issue and included in the response body. ([#5083](https://github.com/diegosouzapw/OmniRoute/issues/5083) — thanks @KooshaPari for the diagnosis and original PR #5084) - **fix(sse): defer `` close so it never leaks before `tool_calls` in Claude→OpenAI streaming** — when a Claude thinking block was followed by a tool_use block, the translator unconditionally emitted a `content: ""` chunk at `content_block_stop`, injecting a spurious assistant text chunk immediately before the `tool_calls` delta and corrupting OpenAI-compatible clients (e.g. Kimi Coding). The close marker is now deferred: it is flushed at the first `text_delta` that follows the thinking block (preserving the #4633 / decolua/9router#454 behavior for Claude Code / Cursor) or at stream finish when no tool_calls were collected. Tool-use streams never get a `text_delta` after the thinking block, so `` is never emitted into content before `tool_calls`. ([#5123](https://github.com/diegosouzapw/OmniRoute/issues/5123)) @@ -51,7 +52,7 @@ _In development — bullets added per PR; finalized at release._ ### 📝 Maintenance -- **test(docker): de-brittle the #4076 builder-stage heap-ordering test (false-failing on a comment)** — `dockerfile-build-heap-4076.test.ts` located the `npm run build` step via a raw `findIndex(/npm run build\b/)`, which matched a **comment line** in the builder stage (`# … npm run build fails with "Module not…"`, added with the v3.8.40 workspace-deps Docker fix) sitting before the `NODE_OPTIONS` heap line — making the test report the heap ceiling as set *after* the build and fail, even though the real `RUN … npm run build` correctly follows the `ENV NODE_OPTIONS` line. Instruction matching now skips Dockerfile comment lines (`# …`), so the ordering guard checks real `ENV`/`RUN` instructions only. The Dockerfile itself was already correct; this was a base-red blocking every PR into `release/v3.8.40`. ([#4076](https://github.com/diegosouzapw/OmniRoute/issues/4076)) +- **test(docker): de-brittle the #4076 builder-stage heap-ordering test (false-failing on a comment)** — `dockerfile-build-heap-4076.test.ts` located the `npm run build` step via a raw `findIndex(/npm run build\b/)`, which matched a **comment line** in the builder stage (`# … npm run build fails with "Module not…"`, added with the v3.8.40 workspace-deps Docker fix) sitting before the `NODE_OPTIONS` heap line — making the test report the heap ceiling as set _after_ the build and fail, even though the real `RUN … npm run build` correctly follows the `ENV NODE_OPTIONS` line. Instruction matching now skips Dockerfile comment lines (`# …`), so the ordering guard checks real `ENV`/`RUN` instructions only. The Dockerfile itself was already correct; this was a base-red blocking every PR into `release/v3.8.40`. ([#4076](https://github.com/diegosouzapw/OmniRoute/issues/4076)) - **test(combo): deterministic context-relay universal-handoff coverage** — covers the universal (provider-agnostic) session-handoff path in `context-relay` (`combo.ts:2099–2139`), which previously had only a definition-order assertion and a `TODO(phase-2)`. The test drives the real pipeline via session seams (`x-session-id` → `relayOptions.sessionId` → `maybeGenerateUniversalHandoff`) without live infrastructure. ([#5168](https://github.com/diegosouzapw/OmniRoute/pull/5168)) - **test(combo): end-to-end quota-share DRR routing-decision coverage (matrix parity)** — adds the missing E2E test for the `quota-share` strategy, driving the real `handleChat` → chatCore → `selectQuotaShareTarget` → executor pipeline via in-process seams and asserting which connection is dispatched. The DRR selector already had 29 unit tests; this closes the E2E gap and brings quota-share to parity with the 17-strategy public matrix. ([#5179](https://github.com/diegosouzapw/OmniRoute/pull/5179)) - **test(combo): deterministic context-relay codex quota-handoff coverage (closes last gap)** — covers the codex-specific handoff block of `context-relay` (`combo.ts:2143–2183`), which #5168 left documented-but-untested because it requires a `codex` connection. All seams (`fetchCodexQuota`, handoff generation, session relay) are mocked deterministically without live infra. ([#5195](https://github.com/diegosouzapw/OmniRoute/pull/5195)) diff --git a/package.json b/package.json index e6b2be3fb3cf..3ff935039c24 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "bin/cli/runtime/", "scripts/postinstall.mjs", "scripts/build/postinstallSupport.mjs", + "scripts/build/runtime-env.mjs", "scripts/build/colocateOptionals.mjs", "scripts/build/sync-env.mjs", "scripts/dev/responses-ws-proxy.mjs", diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 12ed81d7a4a1..91701acc3e10 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -106,6 +106,8 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [ "scripts/build/postinstall.mjs", "scripts/build/postinstallSupport.mjs", "scripts/build/colocateOptionals.mjs", + // #5227: imported at runtime by bin/cli/commands/serve.mjs (heap auto-calibration). + "scripts/build/runtime-env.mjs", "scripts/build/sync-env.mjs", "scripts/dev/responses-ws-proxy.mjs", "scripts/dev/sync-env.mjs", @@ -148,6 +150,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "scripts/build/postinstall.mjs", "scripts/build/postinstallSupport.mjs", "scripts/build/colocateOptionals.mjs", + "scripts/build/runtime-env.mjs", "src/shared/utils/nodeRuntimeSupport.ts", ]; diff --git a/tests/unit/cli-runtime-imports-packaged-5227.test.ts b/tests/unit/cli-runtime-imports-packaged-5227.test.ts new file mode 100644 index 000000000000..26567c4080ac --- /dev/null +++ b/tests/unit/cli-runtime-imports-packaged-5227.test.ts @@ -0,0 +1,86 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, resolve, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Regression guard for #5227. + * + * The published npm package only ships the paths listed in package.json's + * `files` whitelist. If a CLI entrypoint under `bin/` imports a module under + * `scripts/` at runtime, that script MUST be covered by the whitelist — + * otherwise a global install (`npm install -g omniroute`) fails at startup with + * "Cannot find module .../scripts/build/runtime-env.mjs". + * + * #5227: `bin/cli/commands/serve.mjs` started importing + * `scripts/build/runtime-env.mjs` (added with the heap auto-calibration fix + * #5213) but the file was never added to `files`, breaking every global install. + */ + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "../../../"); + +function listMjsFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...listMjsFiles(full)); + } else if (entry.endsWith(".mjs") || entry.endsWith(".js")) { + out.push(full); + } + } + return out; +} + +/** Extract every `scripts/...`-resolving specifier statically/dynamically imported by a bin file. */ +function scriptImports(binFile: string): string[] { + const src = readFileSync(binFile, "utf8"); + const specifiers = new Set(); + // static: from "..." dynamic: import("...") + const re = /(?:from|import)\s*\(?\s*["']([^"']+)["']/g; + let m: RegExpExecArray | null; + while ((m = re.exec(src))) { + const spec = m[1]; + if (!spec.startsWith(".")) continue; // only relative imports can reach scripts/ + const resolved = resolve(dirname(binFile), spec); + const rel = relative(REPO_ROOT, resolved).split("\\").join("/"); + if (rel.startsWith("scripts/")) specifiers.add(rel); + } + return [...specifiers]; +} + +/** A repo-relative path is published if it equals a whitelist entry or sits under a directory entry. */ +function isCovered(relPath: string, files: string[]): boolean { + return files.some((entry) => { + if (entry.startsWith("!")) return false; // negations never add coverage + if (entry === relPath) return true; + const dirEntry = entry.endsWith("/") ? entry : entry + "/"; + return relPath.startsWith(dirEntry); + }); +} + +test("#5227 — every scripts/* module imported by bin/ is in the npm files whitelist", () => { + const pkg = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")); + const files: string[] = pkg.files || []; + assert.ok(files.length > 0, "package.json must declare a files whitelist"); + + const binDir = join(REPO_ROOT, "bin"); + const imported = new Set(); + for (const binFile of listMjsFiles(binDir)) { + for (const spec of scriptImports(binFile)) imported.add(spec); + } + + // Sanity: the known runtime imports must be detected, else the scanner is broken. + assert.ok( + imported.has("scripts/build/runtime-env.mjs"), + "scanner should detect serve.mjs's import of scripts/build/runtime-env.mjs" + ); + + const missing = [...imported].filter((p) => !isCovered(p, files)); + assert.deepEqual( + missing, + [], + `bin/ imports these scripts/* modules that are NOT in package.json "files" (global install will fail): ${missing.join(", ")}` + ); +}); diff --git a/tests/unit/pack-artifact-policy.test.ts b/tests/unit/pack-artifact-policy.test.ts index 08b78d4a5b0f..cb37b2b66f1d 100644 --- a/tests/unit/pack-artifact-policy.test.ts +++ b/tests/unit/pack-artifact-policy.test.ts @@ -101,6 +101,7 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball", "dist/webdav-handler.mjs", "scripts/build/colocateOptionals.mjs", "scripts/build/native-binary-compat.mjs", + "scripts/build/runtime-env.mjs", "src/shared/utils/nodeRuntimeSupport.ts", ]); }); From 6f150cfd2c00eeebbd3036fd2c9591f1c0d0116a Mon Sep 17 00:00:00 2001 From: Ardem2025 Date: Sun, 28 Jun 2026 19:05:27 +0300 Subject: [PATCH 09/61] fix(antigravity): retry excluded accounts via fallback LRU (#5222) Antigravity: retry excluded accounts via fallback LRU + family-inferred 429 cooldown. Test strengthened into a real LRU regression guard; cooldown constant extracted. Integrated into release/v3.8.40. --- src/sse/services/auth.ts | 98 +++++++++-- .../auth-antigravity-account-retry-v2.test.ts | 164 ++++++++++++++++++ 2 files changed, 249 insertions(+), 13 deletions(-) create mode 100644 tests/unit/auth-antigravity-account-retry-v2.test.ts diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 9ca5f83b7475..26bf73f61ad9 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -17,6 +17,7 @@ import { getQuotaWindowStatus, isAccountQuotaExhausted, } from "@/domain/quotaCache"; +import { getQuotaScopeLabelForProvider } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; import { isAccountUnavailable, getUnavailableUntil, @@ -125,6 +126,10 @@ interface CooldownInspectionState { const MIN_QUOTA_THRESHOLD_PERCENT = 1; const MAX_QUOTA_THRESHOLD_PERCENT = 100; const NON_RETRYABLE_MODEL_LOCKOUT_REASONS = new Set(["not_found", "not_found_local"]); +// Antigravity Gemini family 429 with no parseable upstream hint: seed the backoff at +// this base. Real upstream Retry-After hints still win — they flow through +// `exactCooldownMs` (usedUpstreamRetryHint), not this base. (#5222) +const ANTIGRAVITY_FAMILY_INFERRED_BASE_COOLDOWN_MS = 30_000; function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; @@ -908,6 +913,14 @@ function normalizeExcludedConnectionIds( return normalized; } +function formatConnectionPrefixesForLog(ids: Iterable, max = 6): string { + const prefixes = Array.from(ids) + .filter((id) => typeof id === "string" && id.length > 0) + .slice(0, max) + .map((id) => `${id.slice(0, 8)}...`); + return prefixes.length > 0 ? prefixes.join(",") : "none"; +} + function buildQuotaPreflightRateLimitedResult( provider: string, blockedByPreflight: Array<{ @@ -1097,12 +1110,29 @@ export async function getProviderCredentials( if (forcedConnectionId) { connections = connections.filter((conn) => conn.id === forcedConnectionId); } + const activeConnectionsCount = connections.length; + const rawConnectionsCount = connectionsRaw.length; + const blockedByForcedConnection = forcedConnectionId + ? rawConnectionsCount - connections.length + : 0; + const blockedByAllowedConnections = + allowedConnections && allowedConnections.length > 0 + ? Math.max(0, rawConnectionsCount - connections.length - blockedByForcedConnection) + : 0; + const forcedIdForLog = forcedConnectionId ? `${forcedConnectionId.slice(0, 8)}...` : "none"; + log.debug( "AUTH", - `${provider} | total connections: ${connections.length}, excludeIds: ${ - excludedConnectionIds.size > 0 ? Array.from(excludedConnectionIds).join(",") : "none" - }, forcedId: ${forcedConnectionId || "none"}` + `${provider} | active=${activeConnectionsCount}, excluded=${excludedConnectionIds.size} (${formatConnectionPrefixesForLog(excludedConnectionIds)}), forcedId=${forcedIdForLog}, blocked_forced=${blockedByForcedConnection}, blocked_allowed=${blockedByAllowedConnections}` ); + if (provider === "antigravity" && (forcedConnectionId || allowedConnections?.length)) { + const reasons: string[] = []; + if (forcedConnectionId) reasons.push(`forcedConnectionId kept ${connections.length}`); + if (allowedConnections?.length) { + reasons.push(`allowedConnections=${allowedConnections.length}`); + } + log.info("AUTH", `${provider} selection constrained: ${reasons.join(", ")}`); + } if (connections.length === 0) { // Check all connections (including inactive) to see if rate limited @@ -1149,7 +1179,10 @@ export async function getProviderCredentials( // the dashboard sees a misleading "bad_request" code. const terminalConnections = allConnections.filter(isTerminalConnectionStatus); if (terminalConnections.length === allConnections.length) { - const syntheticFallback = await maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds); + const syntheticFallback = await maybeSyntheticNoAuthFallback( + resolvedId, + excludedConnectionIds + ); if (syntheticFallback) return syntheticFallback; const statusCounts = new Map(); @@ -1166,7 +1199,10 @@ export async function getProviderCredentials( }; } } - const syntheticFallback = await maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds); + const syntheticFallback = await maybeSyntheticNoAuthFallback( + resolvedId, + excludedConnectionIds + ); if (syntheticFallback) return syntheticFallback; log.warn("AUTH", `No credentials for ${provider}`); return null; @@ -1195,6 +1231,8 @@ export async function getProviderCredentials( } } + let modelLockedCount = 0; + let familyLockedCount = 0; // Filter out unavailable accounts and excluded connection const availableConnections = connections.filter((c) => { if (excludedConnectionIds.has(c.id)) return false; @@ -1205,8 +1243,18 @@ export async function getProviderCredentials( if (!allowRateLimitedConnections && isAccountUnavailable(c.rateLimitedUntil)) return false; if (isTerminalConnectionStatus(c)) return false; if (provider === "codex" && isCodexScopeUnavailable(c, requestedModel)) return false; - // Per-model lockout: if this specific model is locked on this connection, skip it - if (requestedModel && isModelLocked(provider, c.id, requestedModel)) return false; + // Per-model lockout: if this specific model/family is locked on this connection, skip it + if (requestedModel && isModelLocked(provider, c.id, requestedModel)) { + if ( + provider === "antigravity" && + getQuotaScopeLabelForProvider(provider, requestedModel) === "family" + ) { + familyLockedCount += 1; + } else { + modelLockedCount += 1; + } + return false; + } } return true; }); @@ -1215,6 +1263,12 @@ export async function getProviderCredentials( "AUTH", `${provider} | available: ${availableConnections.length}/${connections.length}` ); + if (provider === "antigravity") { + log.info( + "AUTH", + `${provider} selection candidates model=${requestedModel || "none"}: active=${activeConnectionsCount}, excluded=${excludedConnectionIds.size}, modelLocked=${modelLockedCount}, familyLocked=${familyLockedCount}, eligible=${availableConnections.length}` + ); + } connections.forEach((c) => { const excluded = excludedConnectionIds.has(c.id); const rateLimited = isAccountUnavailable(c.rateLimitedUntil); @@ -1337,7 +1391,10 @@ export async function getProviderCredentials( cooldownModel: allBlockedByModelCooldown ? requestedModel : null, }; } - const syntheticFallback = await maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds); + const syntheticFallback = await maybeSyntheticNoAuthFallback( + resolvedId, + excludedConnectionIds + ); if (syntheticFallback) return syntheticFallback; log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`); return null; @@ -1461,9 +1518,9 @@ export async function getProviderCredentials( } else if (strategy === "round-robin") { const stickyLimit = toNumber((settings as Record).stickyRoundRobinLimit, 3); - // If excluding an account (fallback scenario), skip sticky logic and go straight to LRU - // This prevents the system from getting stuck on a failed account - const isFallbackScenario = excludeConnectionId !== null; + // If excluding account(s) (fallback scenario), skip sticky logic and go straight to LRU. + // This prevents same-model retries from getting stuck on a failed account. + const isFallbackScenario = excludeConnectionId !== null || excludedConnectionIds.size > 0; if (!isFallbackScenario) { // Sort by lastUsed (most recent first) to find current candidate @@ -1533,7 +1590,7 @@ export async function getProviderCredentials( connection = sortedByOldest[0]; log.info( "AUTH", - `${provider} round-robin: FALLBACK MODE - excluded ${excludeConnectionId?.slice(0, 8)}..., picked LRU ${connection.id?.slice(0, 8)}...` + `${provider} round-robin: FALLBACK MODE - excluded_count=${excludedConnectionIds.size} excluded=${formatConnectionPrefixesForLog(excludedConnectionIds)} picked_lru=${connection.id?.slice(0, 8)}...` ); // Update lastUsedAt and reset count to 1 (await to ensure persistence) @@ -1591,6 +1648,13 @@ export async function getProviderCredentials( connection = orderedConnections[0]; } + if (provider === "antigravity" && connection) { + log.info( + "AUTH", + `${provider} selected account=${connection.id?.slice(0, 8)}... eligible=${orderedConnections.length} excluded=${excludedConnectionIds.size}` + ); + } + const apiKeyHealth = connection.providerSpecificData?.apiKeyHealth as | Record | undefined; @@ -1932,6 +1996,11 @@ export async function markAccountUnavailable( : status === 429 ? "rate_limited" : "server_error"; + const quotaScope = getQuotaScopeLabelForProvider(provider, model); + const antigravityFamilyInferredBaseCooldownMs = + provider === "antigravity" && quotaScope === "family" && status === 429 + ? ANTIGRAVITY_FAMILY_INFERRED_BASE_COOLDOWN_MS + : null; const lockout = recordModelLockoutFailure( provider, connectionId, @@ -1940,7 +2009,10 @@ export async function markAccountUnavailable( status, status === 404 ? (effectiveProviderProfile?.baseCooldownMs ?? COOLDOWN_MS.notFoundLocal) - : (fallbackResult.baseCooldownMs ?? effectiveProviderProfile?.baseCooldownMs ?? 0), + : (antigravityFamilyInferredBaseCooldownMs ?? + fallbackResult.baseCooldownMs ?? + effectiveProviderProfile?.baseCooldownMs ?? + 0), effectiveProviderProfile, { ...modelLockoutOptions, diff --git a/tests/unit/auth-antigravity-account-retry-v2.test.ts b/tests/unit/auth-antigravity-account-retry-v2.test.ts new file mode 100644 index 000000000000..e26a0625ccec --- /dev/null +++ b/tests/unit/auth-antigravity-account-retry-v2.test.ts @@ -0,0 +1,164 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-auth-ag-retry-v2-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const auth = await import("../../src/sse/services/auth.ts"); + +type CreatedConnection = { id: string }; + +function connectionId(connection: unknown): string { + assert.ok(connection && typeof connection === "object" && "id" in connection); + const id = (connection as CreatedConnection).id; + assert.equal(typeof id, "string"); + return id; +} + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("round-robin same-model retry treats multi-exclude as fallback LRU and skips all excluded accounts", async () => { + await resetStorage(); + + const first = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + email: "first@example.test", + accessToken: "tok-first", + isActive: true, + testStatus: "active", + priority: 1, + }); + const second = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + email: "second@example.test", + accessToken: "tok-second", + isActive: true, + testStatus: "active", + priority: 2, + }); + // Two NON-excluded eligible accounts with diverging lastUsedAt so sticky + // (most-recently-used) and fallback LRU (least-recently-used) pick different + // accounts — this is what makes the test discriminate the + // `excludedConnectionIds.size > 0` fallback branch. `recent` is the sticky + // pick; `stale` is the LRU pick. + const recent = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + email: "recent@example.test", + accessToken: "tok-recent", + isActive: true, + testStatus: "active", + priority: 3, + }); + const stale = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + email: "stale@example.test", + accessToken: "tok-stale", + isActive: true, + testStatus: "active", + priority: 4, + }); + + const firstId = connectionId(first); + const secondId = connectionId(second); + const recentId = connectionId(recent); + const staleId = connectionId(stale); + + await providersDb.updateProviderConnection(firstId, { + lastUsedAt: new Date(Date.now() - 5_000).toISOString(), + consecutiveUseCount: 1, + }); + await providersDb.updateProviderConnection(secondId, { + lastUsedAt: new Date(Date.now() - 4_000).toISOString(), + consecutiveUseCount: 1, + }); + // `recent` is the most-recently-used eligible account: WITHOUT the fallback + // change, sticky routing (count 1 < limit 3) would stay on it. + await providersDb.updateProviderConnection(recentId, { + lastUsedAt: new Date(Date.now() - 1_000).toISOString(), + consecutiveUseCount: 1, + }); + // `stale` is the least-recently-used eligible account: the fallback LRU branch + // must pick this one. + await providersDb.updateProviderConnection(staleId, { + lastUsedAt: new Date(Date.now() - 90_000).toISOString(), + consecutiveUseCount: 1, + }); + + await settingsDb.updateSettings({ fallbackStrategy: "round-robin", stickyRoundRobinLimit: 3 }); + + const selected = await auth.getProviderCredentials("antigravity", null, null, "gemini-3-pro", { + excludeConnectionIds: [firstId, secondId], + }); + + assert.ok(selected, "expected an eligible non-excluded Antigravity account"); + // Excluded accounts must never be selected. + assert.notEqual(selected.connectionId, firstId); + assert.notEqual(selected.connectionId, secondId); + // The fallback (excludedConnectionIds.size > 0) must route to the LRU account + // (`stale`), NOT the sticky most-recently-used one (`recent`). Without the + // `excludedConnectionIds.size > 0` fallback trigger this assertion gets + // `recentId` and fails. + assert.equal(selected.connectionId, staleId); +}); + +test("Antigravity inferred Gemini family cooldown starts around 30s when no upstream hint exists", async () => { + await resetStorage(); + + const conn = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + email: "quota@example.test", + accessToken: "tok-quota", + isActive: true, + testStatus: "active", + }); + + const before = Date.now(); + const result = await auth.markAccountUnavailable( + connectionId(conn), + 429, + "RESOURCE_EXHAUSTED: Resource has been exhausted (queries per minute limit was reached)", + "antigravity", + "gemini-3-pro" + ); + const elapsedAllowanceMs = Date.now() - before; + + assert.equal(result.shouldFallback, true); + assert.ok( + result.cooldownMs >= 30_000 - elapsedAllowanceMs - 500, + `expected inferred cooldown near 30s+, got ${result.cooldownMs}` + ); + assert.ok( + result.cooldownMs <= 65_000, + `expected bounded initial cooldown, got ${result.cooldownMs}` + ); + + const otherGemini = await auth.getProviderCredentials( + "antigravity", + null, + null, + "gemini-2.5-pro" + ); + assert.ok(otherGemini && "allRateLimited" in otherGemini && otherGemini.allRateLimited); + assert.equal(otherGemini.cooldownScope, "model"); + assert.equal(otherGemini.lastErrorCode, 429); +}); From e8d13ec17bed76fb7b6f9501b3572c594c835285 Mon Sep 17 00:00:00 2001 From: Abhishek Divekar Date: Sun, 28 Jun 2026 13:07:50 -0300 Subject: [PATCH 10/61] fix(command-code): omit max_tokens when client omits it (#5221) Cherry-picked the corrective part of #5221 only: the executor stops fabricating `max_tokens` (= per-model registry cap) when the client omits it, which caused `400 "expected <=200000"` on /alpha/generate for high-cap models. An explicit oversized client value is clamped to the 200k endpoint ceiling. The PR's registry maxOutputTokens recaps (open-sse/config/providers/registry/command-code/index.ts) are intentionally NOT included pending reconciliation; #5221 stays open for that. --- CHANGELOG.md | 4 ++ open-sse/executors/commandCode.ts | 48 +++++++++++++----------- tests/unit/command-code-executor.test.ts | 36 ++++++++++++++---- 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82ed6a04d1a5..1f70a9eb8f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ _In development — bullets added per PR; finalized at release._ +### 🔧 Bug Fixes + +- **command-code:** omit `max_tokens` when the client omits it so the upstream applies the model's native default, fixing `400 "expected <=200000"` on `/alpha/generate` for high-cap models; an explicit oversized client value is clamped to the 200k endpoint ceiling (#5221 — thanks @adivekar-utexas) + --- ## [3.8.39] — 2026-06-28 diff --git a/open-sse/executors/commandCode.ts b/open-sse/executors/commandCode.ts index 76fd7af3b88f..a18100780cd4 100644 --- a/open-sse/executors/commandCode.ts +++ b/open-sse/executors/commandCode.ts @@ -6,6 +6,12 @@ import { BaseExecutor, mergeUpstreamExtraHeaders, type ExecuteInput } from "./ba type JsonRecord = Record; export const COMMAND_CODE_VERSION = process.env.COMMAND_CODE_VERSION?.trim() || "0.33.2"; +// Hard server-side ceiling enforced by Command Code's /alpha/generate endpoint: +// any request with params.max_tokens > 200_000 is rejected with a 400 +// "Too big: expected number to be <=200000 at params.max_tokens". We only use +// this to clamp a CLIENT-SUPPLIED max_tokens down to a value the endpoint will +// accept; we never fabricate this number for requests that omit the field (see +// clampMaxTokens / buildCommandCodeBody). const MAX_COMMAND_CODE_TOKENS = 200_000; const encoder = new TextEncoder(); @@ -140,23 +146,16 @@ function convertMessages(messages: unknown): { system: string; messages: unknown return { system: system.join("\n\n"), messages: out }; } -function clampMaxTokens(value: unknown, cap: number = MAX_COMMAND_CODE_TOKENS): number { - const numeric = numberValue(value) ?? cap; - return Math.max(1, Math.min(Math.floor(numeric), cap)); -} - -// Resolve the per-model max_tokens cap for a given CommandCode model id. -// Falls back to MAX_COMMAND_CODE_TOKENS when the model isn't registered or -// when the registry entry omits maxOutputTokens. Without this, GLM-5.x -// requests get capped at 200_000 and rejected with "限制数值范围[1,131072]". -function getModelMaxTokensCap(modelId: string): number { - const entry = REGISTRY["command-code"]; - if (!entry) return MAX_COMMAND_CODE_TOKENS; - const model = entry.models?.find((m: { id: string }) => m.id === modelId); - const registryCap = (model as { maxOutputTokens?: number } | undefined)?.maxOutputTokens; - return typeof registryCap === "number" && registryCap > 0 - ? registryCap - : MAX_COMMAND_CODE_TOKENS; +// Clamp a client-supplied max_tokens to the endpoint ceiling, mirroring the +// provider-driven clamp in antigravity.ts: we only intervene when the value is +// present AND would otherwise be rejected (> 200_000). A valid value is +// returned floored; anything absent or non-numeric returns undefined so the +// caller can OMIT the field entirely and let Command Code's upstream apply the +// model's own native default (rather than us inventing a number). +function clampMaxTokens(value: unknown): number | undefined { + const numeric = numberValue(value); + if (numeric === undefined) return undefined; + return Math.max(1, Math.min(Math.floor(numeric), MAX_COMMAND_CODE_TOKENS)); } // Reasoning/thinking fields that payload rules or clients may inject and that @@ -188,13 +187,20 @@ function buildCommandCodeBody(model: string, body: unknown, stream = false): Jso messages: converted.messages, tools: convertTools(input.tools), system, - max_tokens: clampMaxTokens( - input.max_tokens ?? input.max_completion_tokens, - getModelMaxTokensCap(resolvedModel) - ), stream: true, }; + // Only forward max_tokens when the client actually supplied one. Omitting it + // lets Command Code's upstream apply the model's own native default, so we + // never invent a value (the old behavior, which sent the wrong number and got + // DeepSeek V4 rejected with "Too big: expected number to be <=200000"). When + // present, it is clamped to the endpoint ceiling so an oversized client value + // degrades gracefully instead of 400ing. + const maxTokens = clampMaxTokens(input.max_tokens ?? input.max_completion_tokens); + if (maxTokens !== undefined) { + params.max_tokens = maxTokens; + } + for (const field of COMMAND_CODE_PASSTHROUGH_FIELDS) { const value = input[field]; if (value !== undefined && value !== null) { diff --git a/tests/unit/command-code-executor.test.ts b/tests/unit/command-code-executor.test.ts index a52f59b62b4f..35ad26325743 100644 --- a/tests/unit/command-code-executor.test.ts +++ b/tests/unit/command-code-executor.test.ts @@ -294,40 +294,60 @@ test("Command Code executor surfaces upstream and streamed errors", async () => }, /boom/); }); -test("Command Code executor caps max_tokens to the registered per-model limit (GLM-5.x)", async () => { +test("Command Code executor omits max_tokens when the client does not supply one (GLM-5.x)", async () => { const calls: FetchCall[] = []; globalThis.fetch = async (url, init = {}) => { calls.push({ url: String(url), init, body: JSON.parse(String(init.body)) }); return commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]); }; - // GLM-5 and GLM-5.1 are registered with maxOutputTokens: 131072. - // Without per-model capping, the upstream rejects with - // "限制数值范围[1,131072]". + // No client max_tokens: we must NOT fabricate one. Omitting the field lets + // Command Code's upstream apply the model's own native default. await getExecutor("command-code").execute({ model: "zai-org/GLM-5.1", stream: false, credentials: { apiKey: "cc_test_key" }, body: { messages: [{ role: "user", content: "Hi" }] }, }); - assert.equal(calls[0].body.params.max_tokens, 131072); + assert.ok(!("max_tokens" in calls[0].body.params)); }); -test("Command Code executor caps max_tokens to the registered per-model limit (DeepSeek v4)", async () => { +test("Command Code executor omits max_tokens for DeepSeek v4 when the client does not supply one", async () => { const calls: FetchCall[] = []; globalThis.fetch = async (url, init = {}) => { calls.push({ url: String(url), init, body: JSON.parse(String(init.body)) }); return commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]); }; - // DeepSeek v4 pro is registered with maxOutputTokens: 384000. + // Regression: previously the executor invented max_tokens from the registry + // (384000), which /alpha/generate rejects with a 400 + // "Too big: expected number to be <=200000". With no client value we now omit + // the field entirely, so the request succeeds and upstream picks the default. await getExecutor("command-code").execute({ model: "deepseek/deepseek-v4-pro", stream: false, credentials: { apiKey: "cc_test_key" }, body: { messages: [{ role: "user", content: "Hi" }] }, }); - assert.equal(calls[0].body.params.max_tokens, 384000); + assert.ok(!("max_tokens" in calls[0].body.params)); +}); + +test("Command Code executor clamps an oversized client-supplied max_tokens to the endpoint ceiling", async () => { + const calls: FetchCall[] = []; + globalThis.fetch = async (url, init = {}) => { + calls.push({ url: String(url), init, body: JSON.parse(String(init.body)) }); + return commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]); + }; + + // A client asking for more than the 200000 endpoint ceiling is clamped down + // (not 400'd), mirroring the provider-driven clamp in antigravity.ts. + await getExecutor("command-code").execute({ + model: "deepseek/deepseek-v4-pro", + stream: false, + credentials: { apiKey: "cc_test_key" }, + body: { messages: [{ role: "user", content: "Hi" }], max_tokens: 500000 }, + }); + assert.equal(calls[0].body.params.max_tokens, 200000); }); test("Command Code executor honors a smaller client-provided max_tokens under the per-model cap", async () => { From d8a392a47766ee3deb17dfdff75ec285cfab0e92 Mon Sep 17 00:00:00 2001 From: Abhishek Divekar Date: Sun, 28 Jun 2026 22:06:22 +0530 Subject: [PATCH 11/61] fix(command-code): omit max_tokens when client omits it; correct registry caps (#5221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.40 — corrective max_tokens part already cherry-picked (e8d13ec17); this brings the registry maxOutputTokens caps. Thanks @adivekar-utexas. --- .../providers/registry/command-code/index.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/open-sse/config/providers/registry/command-code/index.ts b/open-sse/config/providers/registry/command-code/index.ts index b3a35787b8f5..6bc96c2372e6 100644 --- a/open-sse/config/providers/registry/command-code/index.ts +++ b/open-sse/config/providers/registry/command-code/index.ts @@ -74,70 +74,70 @@ export const command_codeProvider: RegistryEntry = { name: "DeepSeek V4 Pro (CC)", supportsReasoning: true, contextLength: 1000000, - maxOutputTokens: 384000, + maxOutputTokens: 131072, }, { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash (CC)", supportsReasoning: true, contextLength: 1000000, - maxOutputTokens: 384000, + maxOutputTokens: 131072, }, { id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6 (CC)", supportsReasoning: true, contextLength: 262144, - maxOutputTokens: 131072, + maxOutputTokens: 65536, }, { id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5 (CC)", supportsReasoning: true, contextLength: 262144, - maxOutputTokens: 131072, + maxOutputTokens: 65536, }, { id: "zai-org/GLM-5.1", name: "GLM-5.1 (CC)", supportsReasoning: true, contextLength: 200000, - maxOutputTokens: 131072, + maxOutputTokens: 32768, }, { id: "zai-org/GLM-5", name: "GLM-5 (CC)", supportsReasoning: true, contextLength: 200000, - maxOutputTokens: 131072, + maxOutputTokens: 32768, }, { id: "MiniMaxAI/MiniMax-M2.7", name: "MiniMax M2.7 (CC)", supportsReasoning: true, contextLength: 1048576, - maxOutputTokens: 131072, + maxOutputTokens: 65536, }, { id: "MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5 (CC)", supportsReasoning: true, contextLength: 1048576, - maxOutputTokens: 131072, + maxOutputTokens: 65536, }, { id: "Qwen/Qwen3.6-Max-Preview", name: "Qwen 3.6 Max (CC)", supportsReasoning: true, contextLength: 1000000, - maxOutputTokens: 131072, + maxOutputTokens: 32768, }, { id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus (CC)", supportsReasoning: true, contextLength: 1000000, - maxOutputTokens: 131072, + maxOutputTokens: 32768, }, ], }; From 0568c37eeef9272c73076f58109b6649049c094f Mon Sep 17 00:00:00 2001 From: backryun Date: Mon, 29 Jun 2026 02:41:56 +0900 Subject: [PATCH 12/61] fix(providers): refresh impersonation User-Agents + TLS profiles to current client versions (#5237) Refresh impersonation UAs + TLS profiles to current client versions. Review fixes (perplexity UA kept on Firefox 148 to match firefox_148 TLS profile; golden snapshot regenerated; release file-size/complexity baselines reconciled) co-authored. Thanks @backryun. --- .env.example | 6 +- config/quality/complexity-baseline.json | 3 +- config/quality/file-size-baseline.json | 9 +- docs/providers/AGENTROUTER.md | 2 +- docs/reference/ENVIRONMENT.md | 406 +++++++++--------- docs/security/STEALTH_GUIDE.md | 8 +- open-sse/config/anthropicHeaders.ts | 12 +- open-sse/config/glmProvider.ts | 2 +- open-sse/config/providerHeaderProfiles.ts | 12 +- open-sse/executors/adapta-web.ts | 36 +- open-sse/executors/blackbox-web.ts | 36 +- open-sse/executors/chatgpt-web.ts | 6 +- open-sse/executors/chipotle.ts | 43 +- open-sse/executors/claude-web.ts | 4 +- open-sse/executors/claudeIdentity.ts | 2 +- open-sse/executors/copilot-web.ts | 2 +- open-sse/executors/deepseek-web.ts | 2 +- open-sse/executors/doubao-web.ts | 18 +- open-sse/executors/duckduckgo-web.ts | 4 +- open-sse/executors/gemini-business.ts | 27 +- open-sse/executors/gemini-web.ts | 2 +- open-sse/executors/grok-cli.ts | 4 +- open-sse/executors/grok-web.ts | 12 +- open-sse/executors/huggingchat.ts | 63 ++- open-sse/executors/inner-ai.ts | 31 +- open-sse/executors/kimi-web.ts | 18 +- open-sse/executors/lmarena.ts | 21 +- open-sse/executors/mimocode.ts | 11 +- open-sse/executors/muse-spark-web.ts | 30 +- open-sse/executors/perplexity-web.ts | 39 +- open-sse/executors/phind.ts | 5 +- open-sse/executors/poe-web.ts | 2 +- open-sse/executors/qwen-web.ts | 2 +- open-sse/executors/t3-chat-web.ts | 178 ++++---- open-sse/executors/theoldllm.ts | 58 +-- open-sse/executors/trae.ts | 2 +- open-sse/executors/v0-vercel-web.ts | 2 +- open-sse/executors/venice-web.ts | 2 +- open-sse/executors/veoaifree-web.ts | 9 +- open-sse/services/antigravityHeaders.ts | 4 +- open-sse/services/autoRefreshDaemon.ts | 2 +- open-sse/services/browserBackedChat.ts | 4 +- open-sse/services/browserPool.ts | 4 +- open-sse/services/ccBridgeTransforms.ts | 4 +- open-sse/services/claudeCodeCompatible.ts | 4 +- open-sse/services/claudeTlsClient.ts | 2 +- open-sse/services/claudeTurnstileSolver.ts | 2 +- open-sse/services/freeWebSearch.ts | 2 +- open-sse/services/geminiCliHeaders.ts | 2 +- open-sse/services/grokTlsClient.ts | 2 +- open-sse/services/opencodeOllamaUsage.ts | 4 +- .../sessionPool/fingerprintRotator.ts | 30 +- open-sse/services/usage.ts | 2 +- open-sse/utils/cursorVersionDetector.ts | 2 +- scripts/ad-hoc/diag-trae-auth.mjs | 2 +- scripts/dev/responses-ws-proxy.mjs | 2 +- .../api/internal/codex-responses-ws/route.ts | 2 +- src/app/api/providers/[id]/models/route.ts | 2 +- src/lib/providers/validation/headers.ts | 8 +- src/lib/providers/validation/metaAi.ts | 5 +- src/lib/providers/validation/webProvidersA.ts | 17 +- src/lib/providers/validation/webProvidersB.ts | 4 +- tests/snapshots/provider/translate-path.json | 66 +-- tests/unit/antigravity-client-profile.test.ts | 22 +- ...claude-codex-identity-version-sync.test.ts | 4 +- tests/unit/cli-tools.test.ts | 2 +- tests/unit/executor-github.test.ts | 31 +- tests/unit/glm-executor.test.ts | 38 +- tests/unit/t20-t22-provider-headers.test.ts | 8 +- tests/unit/usage-service-hardening.test.ts | 8 +- 70 files changed, 816 insertions(+), 606 deletions(-) diff --git a/.env.example b/.env.example index ba7e3305694c..06d373e64e67 100644 --- a/.env.example +++ b/.env.example @@ -834,7 +834,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup. # Update these when providers release new CLI versions to avoid blocks. -CLAUDE_USER_AGENT="claude-cli/2.1.187 (external, cli)" +CLAUDE_USER_AGENT="claude-cli/2.1.195 (external, cli)" # Disable the deterministic tool-name cloak applied on both Anthropic-bound paths # (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) — @@ -844,7 +844,7 @@ CLAUDE_USER_AGENT="claude-cli/2.1.187 (external, cli)" # forward the original names verbatim (debugging only). # CLAUDE_DISABLE_TOOL_NAME_CLOAK=false CODEX_USER_AGENT="codex-cli/0.142.0 (Windows 10.0.26200; x64)" -GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1" +GITHUB_USER_AGENT="GitHubCopilotChat/0.54.0" ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0" KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0" # KIRO_VERIFY_FULL_CRC=false # opt-in: full per-frame message CRC validation on the Kiro event stream (debug corrupted streams; prelude CRC + TLS already protect framing) @@ -859,7 +859,7 @@ KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0" # Used by: open-sse/executors/kiro.ts # KIRO_VERIFY_FULL_CRC=false QODER_USER_AGENT="Qoder-Cli" -QWEN_USER_AGENT="QwenCode/0.15.11 (linux; x64)" +QWEN_USER_AGENT="QwenCode/0.19.3 (linux; x64)" CURSOR_USER_AGENT="Cursor/3.4" GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index f62a65767f5e..d429f43987a4 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,6 +1,7 @@ { "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", - "count": 1980, + "count": 1981, + "_rebaseline_2026_06_28_v3840_5237_reconcile": "1980->1981 (+1). Inherited release/v3.8.40 drift surfaced while merging PR #5237 (impersonation-UA refresh) — the +1 is present on the pristine release tip (d8a392a47) WITHOUT #5237's changes, so it is #5222 (antigravity fallback-LRU retry) / #5221 (command-code) growth that merged via --admin without ratcheting complexity (the PR->release fast-gates do not run check:complexity). #5237 itself is complexity-net-zero: its only edits are a single UA constant, a regenerated golden snapshot, and baseline JSONs. Structural reduction tracked in #3501.", "_rebaseline_2026_06_27_v3838_release": "1978->1980 (+2). v3.8.38 cycle-close drift surfaced by the release-green pre-flight (check:complexity does NOT run on PR->release fast-gates). +2 from late-cycle feature/fix merges (compression fidelity-gate steps #5143, SSE hardening). Release-finalize working tree touches ONLY CHANGELOG.md + i18n mirrors + the 2 baseline JSONs — 0 production-code change. Structural reduction tracked in #3501.", "_rebaseline_2026_06_26_v3838_ownerprs_batch": "1972->1978 (+6). Drift do lote de merges de PRs do dono + contribuidores em release/v3.8.38 (sessao /review-prs): #4845 (antigravity convertGeminiToOpenAI), #5105 (executor zenmux-free), #5020 (executor grok-cli), #4940 (usage dedupe guard), #5093 (resilience: quota cutoff/gemini mime/model-lockout cooldown), #5015 (quota hydration + auto-combo scoping). Cada um e crescimento de feature/fix legitimo recem-TDD'd, nao regressao; o gate check:complexity NAO roda no fast-path PR->release, entao o ramo acumula sem rebaselinar (mesma familia dos rebaselines anteriores). #5121 cookie-dedup foi mantido complexity-NEUTRO via extracao do helper findExistingCookieConnection. Reducao estrutural fica como debt (#3501).", "_rebaseline_2026_06_26_v3838_release_fast_gate": "1963->1972 (+9). Reconciles inherited release/v3.8.38 drift surfaced by PR #5124. Local origin/release/v3.8.38 measured 1971 and this PR also measured 1971 after refactoring the new JSON-body SSE sniffing path, while the GitHub Ubuntu fast gate measured 1972; use the CI-observed value so the release branch gate is deterministic. The streaming fix is complexity-net-zero relative to the local release base. The release fast-path does not consistently ratchet complexity between release-cycle merges; keep structural reductions as separate debt.", diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 3d25670d6c01..b6a8503d3915 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -135,10 +135,11 @@ "open-sse/executors/deepseek-web.ts": 1148, "_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.", "_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.", + "_rebaseline_2026_06_28_5237_impersonation_ua_refresh": "PR #5237 (refresh impersonation UAs): grok-web.ts 1871->1873 (+2), muse-spark-web.ts 1284->1302 (+18), perplexity-web.ts 1013->1032 (+19). Net semantic change in each file is a single User-Agent constant (Chrome 147->149 for grok/muse; perplexity kept at Firefox 148 to stay matched with the firefox_148 TLS profile — the contributor's 152 bump was reverted to avoid a UA-vs-JA3 mismatch, #2459). The growth is Prettier reflow that lint-staged unavoidably applies to these grandfathered long-line files the moment they are touched; not extractable. src/sse/services/auth.ts 2336->2401 in the same reconcile is #5222's antigravity-LRU-retry growth that merged via --admin without a baseline bump.", "open-sse/executors/duckduckgo-web.ts": 925, - "open-sse/executors/grok-web.ts": 1871, - "open-sse/executors/muse-spark-web.ts": 1284, - "open-sse/executors/perplexity-web.ts": 1013, + "open-sse/executors/grok-web.ts": 1873, + "open-sse/executors/muse-spark-web.ts": 1302, + "open-sse/executors/perplexity-web.ts": 1032, "open-sse/handlers/audioSpeech.ts": 1061, "open-sse/handlers/chatCore.ts": 5125, "open-sse/handlers/imageGeneration.ts": 3777, @@ -239,7 +240,7 @@ "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2523, "src/sse/handlers/chat.ts": 1575, - "src/sse/services/auth.ts": 2336, + "src/sse/services/auth.ts": 2401, "open-sse/executors/default.ts": 876, "open-sse/translator/request/openai-responses.ts": 902, "open-sse/executors/kiro.ts": 944, diff --git a/docs/providers/AGENTROUTER.md b/docs/providers/AGENTROUTER.md index 8e143bd51670..e0c8a203ea74 100644 --- a/docs/providers/AGENTROUTER.md +++ b/docs/providers/AGENTROUTER.md @@ -124,7 +124,7 @@ request (see `open-sse/services/claudeCodeCompatible.ts`): | Header | Value | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `Authorization` | `Bearer ` | -| `User-Agent` | `claude-cli/2.1.187 (external, sdk-cli)` | +| `User-Agent` | `claude-cli/2.1.195 (external, sdk-cli)` | | `anthropic-version` | `2023-06-01` | | `anthropic-beta` | `claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24` | | Per-connection redact-thinking beta toggle | Adds `redact-thinking-2026-02-12` for upstreams that specifically require redacted thinking streams | diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 6008d3f8c6e6..f6e3879162c0 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -179,7 +179,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `CORS_ORIGIN` | `*` | Next.js middleware | CORS `Access-Control-Allow-Origin` value. Restrict for production. | | `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. | | `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, 10.x.x.x, etc.). **REQUIRED for self-hosted providers** (LM Studio, Ollama, vLLM, Llamafile, Triton, SearXNG). When `false`, the dashboard rejects validation of local URLs. | -| `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | `true` | `src/shared/network/outboundUrlGuard.ts` | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN, private ranges) — scoped to the provider validation path. **Default `true`** (local-first); set `false` to enforce strict public-only blocking. Cloud-metadata endpoints (169.254.169.254, metadata.google.internal) stay blocked regardless. (#5066) | +| `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | `true` | `src/shared/network/outboundUrlGuard.ts` | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN, private ranges) — scoped to the provider validation path. **Default `true`** (local-first); set `false` to enforce strict public-only blocking. Cloud-metadata endpoints (169.254.169.254, metadata.google.internal) stay blocked regardless. (#5066) | ### Hardening Checklist @@ -286,19 +286,19 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress control, geo-routing, or IP masking. -| Variable | Default | Source File | Description | -| ---------------------------------------- | --------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | Enable SOCKS5 proxy agent for upstream calls. Opt-out with `false`. | -| `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `true` | Client-side | Client-side awareness of SOCKS5 availability. | -| `HTTP_PROXY` | _(unset)_ | Node.js standard | HTTP proxy for upstream calls. | -| `HTTPS_PROXY` | _(unset)_ | Node.js standard | HTTPS proxy for upstream calls. | -| `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). | -| `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. | -| `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS` | `32` | `open-sse/utils/proxyDispatcher.ts` | Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher. Long-lived SSE streams such as Codex `/v1/responses` need more than one connection when several requests share the same account-level proxy. Values above `256` are capped. | +| Variable | Default | Source File | Description | +| ---------------------------------------- | --------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | Enable SOCKS5 proxy agent for upstream calls. Opt-out with `false`. | +| `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `true` | Client-side | Client-side awareness of SOCKS5 availability. | +| `HTTP_PROXY` | _(unset)_ | Node.js standard | HTTP proxy for upstream calls. | +| `HTTPS_PROXY` | _(unset)_ | Node.js standard | HTTPS proxy for upstream calls. | +| `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). | +| `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. | +| `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS` | `32` | `open-sse/utils/proxyDispatcher.ts` | Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher. Long-lived SSE streams such as Codex `/v1/responses` need more than one connection when several requests share the same account-level proxy. Values above `256` are capped. | | `SOCKS_HANDSHAKE_TIMEOUT_MS` | `10000` | `open-sse/utils/socksConnectorWithFamily.ts` | SOCKS5 handshake (connect) timeout in ms. Raise it when a single residential gateway host is hit by high concurrency (e.g. 100 simultaneous requests) — the real handshake can exceed 10s under a saturated pool even though the proxy is reachable, which otherwise surfaces as a false `[Proxy Fast-Fail] Proxy unreachable`. Capped at `120000`. | -| `PROXY_FAIL_OPEN` | `false` | `src/sse/handlers/chatHelpers.ts` | When `false` (default), a request whose assigned proxy fails to resolve is **refused (fail-closed)** rather than falling back to a direct connection — prevents real-IP leaks. Set `true` to restore the legacy DIRECT fallback. | -| `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. | -| `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors. | +| `PROXY_FAIL_OPEN` | `false` | `src/sse/handlers/chatHelpers.ts` | When `false` (default), a request whose assigned proxy fails to resolve is **refused (fail-closed)** rather than falling back to a direct connection — prevents real-IP leaks. Set `true` to restore the legacy DIRECT fallback. | +| `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. | +| `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors. | ### Scenarios @@ -370,34 +370,34 @@ detection above). ## 10. Internal Agent & MCP Integrations -| Variable | Default | Source File | Description | -| ----------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `OMNIROUTE_BASE_URL` | auto-detect | `open-sse/mcp-server/server.ts` | Explicit URL for MCP/A2A tools to reach OmniRoute. Overrides localhost auto-detection. | -| `OMNIROUTE_API_KEY` | _(unset)_ | MCP/A2A modules | API key for internal MCP tool and A2A skill calls. | -| `OMNIROUTE_API_KEY_ID` | _(unset)_ | `open-sse/mcp-server/audit.ts` | Key ID for MCP audit log attribution. | -| `ROUTER_API_KEY` | _(unset)_ | Legacy | Legacy alias for `OMNIROUTE_API_KEY`. | -| `OMNIROUTE_CONTEXT` | _(active context)_ | `bin/cli/program.mjs`, `bin/cli/api.mjs` | CLI remote-mode context/profile for `omniroute` commands; overrides the active context in the local contexts store. Equivalent to `--context `. | -| `OMNIROUTE_MCP_ENFORCE_SCOPES` | `false` | `open-sse/mcp-server/server.ts` | Enforce scope-based access control on MCP tool calls. | -| `OMNIROUTE_MCP_SCOPES` | _(all)_ | `open-sse/mcp-server/server.ts` | Comma-separated scopes: `admin`, `combos`, `health`, `models`, `routing`, `budget`, `metrics`, `pricing`, `memory`, `skills`. | -| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | enabled | `open-sse/mcp-server/descriptionCompressor.ts` | Compress MCP tool descriptions before serializing the manifest. Disable values: `0`, `false`, `off`. | -| `OMNIROUTE_MCP_DESCRIPTION_COMPRESSION` | `rtk` | `open-sse/mcp-server/descriptionCompressor.ts` | Compression algorithm/profile. Disable values: `0`, `false`, `off`. | -| `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | Model catalog sync interval in hours. | -| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | Provider rate-limit and quota polling interval. | -| `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | Gap (ms) between consecutive OAuth quota fetches in a bulk sync; OAuth connections are fetched one at a time to avoid bursting an upstream. `0` opts out (concurrent). | -| `PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS` | `5000` | `src/lib/usage/providerLimits.ts` | Delay (ms) before refreshing provider limits after a real usage event, giving the upstream quota API time to register consumption. | -| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | Disable all background services (sync, pricing, model refresh). Useful for CI/test. | -| `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | _(unset)_ | `src/lib/config/runtimeSettings.ts` | Force background tasks on under automated test detection. Set `1` to override the test heuristic. | -| `OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS` | `600000` | `src/lib/jobs/budgetResetJob.ts` | Budget reset check cadence (ms). Floor `10000`. | +| Variable | Default | Source File | Description | +| ----------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_BASE_URL` | auto-detect | `open-sse/mcp-server/server.ts` | Explicit URL for MCP/A2A tools to reach OmniRoute. Overrides localhost auto-detection. | +| `OMNIROUTE_API_KEY` | _(unset)_ | MCP/A2A modules | API key for internal MCP tool and A2A skill calls. | +| `OMNIROUTE_API_KEY_ID` | _(unset)_ | `open-sse/mcp-server/audit.ts` | Key ID for MCP audit log attribution. | +| `ROUTER_API_KEY` | _(unset)_ | Legacy | Legacy alias for `OMNIROUTE_API_KEY`. | +| `OMNIROUTE_CONTEXT` | _(active context)_ | `bin/cli/program.mjs`, `bin/cli/api.mjs` | CLI remote-mode context/profile for `omniroute` commands; overrides the active context in the local contexts store. Equivalent to `--context `. | +| `OMNIROUTE_MCP_ENFORCE_SCOPES` | `false` | `open-sse/mcp-server/server.ts` | Enforce scope-based access control on MCP tool calls. | +| `OMNIROUTE_MCP_SCOPES` | _(all)_ | `open-sse/mcp-server/server.ts` | Comma-separated scopes: `admin`, `combos`, `health`, `models`, `routing`, `budget`, `metrics`, `pricing`, `memory`, `skills`. | +| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | enabled | `open-sse/mcp-server/descriptionCompressor.ts` | Compress MCP tool descriptions before serializing the manifest. Disable values: `0`, `false`, `off`. | +| `OMNIROUTE_MCP_DESCRIPTION_COMPRESSION` | `rtk` | `open-sse/mcp-server/descriptionCompressor.ts` | Compression algorithm/profile. Disable values: `0`, `false`, `off`. | +| `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | Model catalog sync interval in hours. | +| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | Provider rate-limit and quota polling interval. | +| `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | Gap (ms) between consecutive OAuth quota fetches in a bulk sync; OAuth connections are fetched one at a time to avoid bursting an upstream. `0` opts out (concurrent). | +| `PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS` | `5000` | `src/lib/usage/providerLimits.ts` | Delay (ms) before refreshing provider limits after a real usage event, giving the upstream quota API time to register consumption. | +| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | Disable all background services (sync, pricing, model refresh). Useful for CI/test. | +| `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | _(unset)_ | `src/lib/config/runtimeSettings.ts` | Force background tasks on under automated test detection. Set `1` to override the test heuristic. | +| `OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS` | `600000` | `src/lib/jobs/budgetResetJob.ts` | Budget reset check cadence (ms). Floor `10000`. | | `OMNIROUTE_CONNECTION_RECOVERY_INTERVAL_MS` | `60000` | `src/lib/quota/connectionRecovery.ts` | Proactive connection-cooldown recovery cadence (ms): re-validates connections whose transient `rate_limited_until` has elapsed, off the request hot path. Floor `5000`. | -| `OMNIROUTE_DISABLE_CONNECTION_RECOVERY` | `false` | `src/lib/quota/connectionRecovery.ts` | Disable the proactive connection-cooldown recovery scheduler (lazy recovery in `getProviderCredentials` still applies). | -| `OMNIROUTE_REASONING_CACHE_CLEANUP_INTERVAL_MS` | `1800000` | `src/lib/jobs/reasoningCacheCleanupJob.ts` | Reasoning cache cleanup cadence (ms). Floor `60000`. | -| `OMNIROUTE_CONFIG_HOT_RELOAD_MS` | `5000` | `src/lib/config/hotReload.ts` | Polling interval (ms) for config hot-reload. Lower than `1000` is rejected. | -| `OMNIROUTE_DISABLE_REDIS_AUTH_CACHE` | _(enabled)_ | `src/lib/db/apiKeys.ts` | Set `1` to bypass the Redis-backed API-key auth cache (forces DB reads). | -| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | `0` | `open-sse/services/compression/engines/rtk/filterLoader.ts` | Trust user-managed RTK project filter rules without strict signature checks. | -| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. | -| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | Escape hatch: allow request body to override the Antigravity project field. | -| `ANTIGRAVITY_CREDITS` | _(unset)_ | `open-sse/services/antigravityCredits.ts` | Override Antigravity's advertised remaining credits (testing / forced values). | -| `AGY_TOKEN_FILE` | `~/.gemini/antigravity-cli/antigravity-oauth-token` | `src/app/api/providers/agy-auth/apply-local/route.ts` | Override the Antigravity CLI (agy) token-file path for the auto-detect local login import. | +| `OMNIROUTE_DISABLE_CONNECTION_RECOVERY` | `false` | `src/lib/quota/connectionRecovery.ts` | Disable the proactive connection-cooldown recovery scheduler (lazy recovery in `getProviderCredentials` still applies). | +| `OMNIROUTE_REASONING_CACHE_CLEANUP_INTERVAL_MS` | `1800000` | `src/lib/jobs/reasoningCacheCleanupJob.ts` | Reasoning cache cleanup cadence (ms). Floor `60000`. | +| `OMNIROUTE_CONFIG_HOT_RELOAD_MS` | `5000` | `src/lib/config/hotReload.ts` | Polling interval (ms) for config hot-reload. Lower than `1000` is rejected. | +| `OMNIROUTE_DISABLE_REDIS_AUTH_CACHE` | _(enabled)_ | `src/lib/db/apiKeys.ts` | Set `1` to bypass the Redis-backed API-key auth cache (forces DB reads). | +| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | `0` | `open-sse/services/compression/engines/rtk/filterLoader.ts` | Trust user-managed RTK project filter rules without strict signature checks. | +| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. | +| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | Escape hatch: allow request body to override the Antigravity project field. | +| `ANTIGRAVITY_CREDITS` | _(unset)_ | `open-sse/services/antigravityCredits.ts` | Override Antigravity's advertised remaining credits (testing / forced values). | +| `AGY_TOKEN_FILE` | `~/.gemini/antigravity-cli/antigravity-oauth-token` | `src/app/api/providers/agy-auth/apply-local/route.ts` | Override the Antigravity CLI (agy) token-file path for the auto-detect local login import. | ### OAuth CLI Bridge (Internal) @@ -473,17 +473,17 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | -------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.195 (external, cli)` | When Anthropic releases a new CLI version | | `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | Set to `1`/`true` to forward third-party harness tool names verbatim to Anthropic on both Anthropic-bound paths (native OAuth and CLIProxyAPI). By default the executor deterministically aliases non-Claude-Code tool names (Claude Code canonical mapping where one exists, otherwise PascalCase) and reverses them on the response via `_toolNameMap`, so harnesses with snake_case tools are not refused as fingerprinted third-party clients. Debugging only. | | `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | -| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | +| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` | When GitHub Copilot Chat updates | | `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | When Antigravity IDE updates | | `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | When Kiro IDE updates | | `KIRO_OAUTH_CLIENT_ID` | `kiro-cli` | Override the Kiro social device-code `clientId` (public id) | | `KIRO_VERIFY_FULL_CRC` | `false` | Opt-in: full per-frame message CRC validation on the Kiro event stream (debug corrupted streams) | | `QODER_USER_AGENT` | `Qoder-Cli` | When Qoder CLI updates | -| `QWEN_USER_AGENT` | `QwenCode/0.15.9 (linux; x64)` | When Qwen Code updates | +| `QWEN_USER_AGENT` | `QwenCode/0.19.3 (linux; x64)` | When Qwen Code updates | | `CURSOR_USER_AGENT` | `Cursor/3.3` | When Cursor updates | | `GEMINI_CLI_USER_AGENT` | `google-api-nodejs-client/10.3.0` | When Google API client updates | @@ -574,35 +574,35 @@ REQUEST_TIMEOUT_MS (global override) └── API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS (default: 0 = disabled) ``` -| Variable | Default | Description | -| ---------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | _(unset)_ | Global shortcut — overrides both `FETCH_TIMEOUT_MS` and `STREAM_IDLE_TIMEOUT_MS` defaults. | -| `FETCH_TIMEOUT_MS` | `600000` | Total HTTP request timeout for upstream provider calls. | -| `STREAM_IDLE_TIMEOUT_MS` | `600000` | Max silence between SSE chunks before aborting. Extended-thinking models rarely pause >90s. | -| `STREAM_READINESS_TIMEOUT_MS` | `80000` | Time to receive the first non-ping SSE event. Inherits `REQUEST_TIMEOUT_MS` when set. | -| `OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS` | _(off)_ | Strip non-standard `codex.*` SSE events (e.g. `codex.rate_limits`) that break the OpenAI SDK's `responses.stream()` with a 502. Set `true`/`1`/`yes` to enable. | -| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive response headers. | -| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive the full response body. | -| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP connection establishment timeout. | -| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. | -| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS fingerprint proxy (wreq-js) timeout. | -| `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | Proxy hop timeout for `/v1` bridge requests. | -| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `300000` | Overall server request timeout for the bridge. | -| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Time to send response headers via the bridge. | -| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Bridge keep-alive idle timeout. | -| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Raw socket timeout (0 = disabled). | -| `SHUTDOWN_TIMEOUT_MS` | `30000` | Grace period on SIGTERM/SIGINT before force-exit. | -| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | Fallback used by `src/shared/utils/fetchTimeout.ts` when `FETCH_TIMEOUT_MS` is unset. | -| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`chatgptTlsClient.ts`). | -| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | -| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`claudeTlsClient.ts`). | -| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | -| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`perplexityTlsClient.ts`). | -| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | -| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`grokTlsClient.ts`). | -| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | -| `OMNIROUTE_BROWSER_POOL` | `on` | Shared Playwright browser pool for browser-backed web-cookie chat (`browserPool.ts`); set `off` to disable. | -| `WEB_COOKIE_USE_BROWSER` | `0` | Opt a web-cookie chat request into the browser-backed path (`browserBackedChat.ts`); `1` to enable. | +| Variable | Default | Description | +| ----------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | _(unset)_ | Global shortcut — overrides both `FETCH_TIMEOUT_MS` and `STREAM_IDLE_TIMEOUT_MS` defaults. | +| `FETCH_TIMEOUT_MS` | `600000` | Total HTTP request timeout for upstream provider calls. | +| `STREAM_IDLE_TIMEOUT_MS` | `600000` | Max silence between SSE chunks before aborting. Extended-thinking models rarely pause >90s. | +| `STREAM_READINESS_TIMEOUT_MS` | `80000` | Time to receive the first non-ping SSE event. Inherits `REQUEST_TIMEOUT_MS` when set. | +| `OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS` | _(off)_ | Strip non-standard `codex.*` SSE events (e.g. `codex.rate_limits`) that break the OpenAI SDK's `responses.stream()` with a 502. Set `true`/`1`/`yes` to enable. | +| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive response headers. | +| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive the full response body. | +| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP connection establishment timeout. | +| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. | +| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS fingerprint proxy (wreq-js) timeout. | +| `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | Proxy hop timeout for `/v1` bridge requests. | +| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `300000` | Overall server request timeout for the bridge. | +| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Time to send response headers via the bridge. | +| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Bridge keep-alive idle timeout. | +| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Raw socket timeout (0 = disabled). | +| `SHUTDOWN_TIMEOUT_MS` | `30000` | Grace period on SIGTERM/SIGINT before force-exit. | +| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | Fallback used by `src/shared/utils/fetchTimeout.ts` when `FETCH_TIMEOUT_MS` is unset. | +| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`chatgptTlsClient.ts`). | +| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | +| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`claudeTlsClient.ts`). | +| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | +| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`perplexityTlsClient.ts`). | +| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | +| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`grokTlsClient.ts`). | +| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | +| `OMNIROUTE_BROWSER_POOL` | `on` | Shared Playwright browser pool for browser-backed web-cookie chat (`browserPool.ts`); set `off` to disable. | +| `WEB_COOKIE_USE_BROWSER` | `0` | Opt a web-cookie chat request into the browser-backed path (`browserBackedChat.ts`); `1` to enable. | Combo target attempts inherit the resolved upstream request timeout (`FETCH_TIMEOUT_MS`, or `REQUEST_TIMEOUT_MS` when it supplies the fetch default). Set `targetTimeoutMs` in a combo, @@ -613,16 +613,16 @@ current upstream timeout are capped to the upstream timeout. Provider-level circuit breaker tuning. Defaults reflect the scaled values used since v3.6 for 500+ connections. -| Variable | Default | Source File | Description | -| --------------------------------------------- | ------- | ------------------------------ | --------------------------------------------------------------------------- | -| `OMNIROUTE_CIRCUIT_BREAKER_OAUTH_THRESHOLD` | `8` | `open-sse/config/constants.ts` | Consecutive failure threshold for OAuth providers before the breaker trips. | -| `OMNIROUTE_CIRCUIT_BREAKER_OAUTH_RESET_MS` | `60000` | `open-sse/config/constants.ts` | Reset window (ms) for OAuth provider breaker. | -| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_THRESHOLD` | `12` | `open-sse/config/constants.ts` | Consecutive failure threshold for API-key providers. | -| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS` | `30000` | `open-sse/config/constants.ts` | Reset window (ms) for API-key provider breaker. | -| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD` | `2` | `open-sse/config/constants.ts` | Consecutive failure threshold for local providers (Ollama, LM Studio, ...). | -| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS` | `15000` | `open-sse/config/constants.ts` | Reset window (ms) for local provider breaker. | +| Variable | Default | Source File | Description | +| --------------------------------------------- | ------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_CIRCUIT_BREAKER_OAUTH_THRESHOLD` | `8` | `open-sse/config/constants.ts` | Consecutive failure threshold for OAuth providers before the breaker trips. | +| `OMNIROUTE_CIRCUIT_BREAKER_OAUTH_RESET_MS` | `60000` | `open-sse/config/constants.ts` | Reset window (ms) for OAuth provider breaker. | +| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_THRESHOLD` | `12` | `open-sse/config/constants.ts` | Consecutive failure threshold for API-key providers. | +| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS` | `30000` | `open-sse/config/constants.ts` | Reset window (ms) for API-key provider breaker. | +| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD` | `2` | `open-sse/config/constants.ts` | Consecutive failure threshold for local providers (Ollama, LM Studio, ...). | +| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS` | `15000` | `open-sse/config/constants.ts` | Reset window (ms) for local provider breaker. | | `PIN_DROP_BACKOFF_LEVEL` | `2` | `open-sse/services/combo.ts` | Backoff depth at which a context-cache pin's provider is deemed durably unhealthy and the pin is dropped for failover. | -| `PIN_DROP_GRACE_MS` | `20000` | `open-sse/services/combo.ts` | Anti-flap window (ms) tolerating brief transient cooldowns before dropping a context-cache pin. | +| `PIN_DROP_GRACE_MS` | `20000` | `open-sse/services/combo.ts` | Anti-flap window (ms) tolerating brief transient cooldowns before dropping a context-cache pin. | ### Scenarios @@ -743,33 +743,33 @@ Automatic model pricing data synchronization from external sources. ## 20. Provider-Specific Settings -| Variable | Default | Source File | Description | -| ----------------------------------------- | ------------------ | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `OPENROUTER_CATALOG_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterCatalog.ts` | OpenRouter model catalog cache TTL. | -| `MODEL_CATALOG_INCLUDE_NAMES` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | Include display-friendly `name` fields in `/v1/models` responses. Disable for clients that expect IDs only. | -| `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | Max wait for NanoBanana image generation jobs. | -| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. | -| `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). | -| `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. | -| `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. | -| `CLOUDFLARE_API_BASE` | `https://api.cloudflare.com/client/v4` | `src/app/api/settings/proxy/cloudflare-deploy/route.ts` | Override the Cloudflare REST API base used by the proxy-pool Workers relay deployer (#4640 / 9router#1360). | -| `NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx` | Default worker project name suggested in the proxy-pool "Deploy Relay" modal. | -| `NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | Set to `false` to hide the Cloudflare Workers relay option from the Proxy Pool tab. | -| `CLOUDFLARED_BIN` | auto-detect | `src/lib/cloudflaredTunnel.ts` | Custom path to `cloudflared` binary. | -| `DENO_DEPLOY_API_BASE` | `https://api.deno.com/v2` | `src/app/api/settings/proxy/deno-deploy/route.ts` | Override the Deno Deploy REST API base used by the proxy-pool relay deployer (#4643 / 9router#1437). | -| `NEXT_PUBLIC_DENO_RELAY_DEFAULT_PROJECT` | `omniroute-deno-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx` | Default Deno Deploy app name suggested in the proxy-pool "Deploy Relay" modal. | -| `NEXT_PUBLIC_DENO_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | Set to `false` to hide the Deno Deploy relay option from the Proxy Pool tab. | -| `SEARCH_CACHE_TTL_MS` | `300000` (5 min) | `open-sse/services/searchCache.ts` | TTL for search API (Perplexity, Brave, etc.) response caching. | -| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | `false` | `src/app/api/providers/route.ts` | Allow multiple simultaneous connections per OpenAI-compatible provider. | -| `ENABLE_CC_COMPATIBLE_PROVIDER` | `false` | `src/shared/utils/featureFlags.ts` | Reveal the experimental CC-compatible provider UI for Claude Code-only relays. | -| `NINEROUTER_HOST` | `127.0.0.1` | `open-sse/executors/ninerouter.ts` | Override the host where the embedded 9router instance listens. | -| `NINEROUTER_PORT` | `20130` | `open-sse/executors/ninerouter.ts` | Override the port where the embedded 9router instance listens. | -| `EMBED_WS_PROXY_HOST` | `127.0.0.1` | `src/lib/services/embedWsProxy.ts` | Bind host for the embedded-service WebSocket proxy (loopback only by default). | -| `EMBED_WS_PROXY_PORT` | `20131` | `src/lib/services/embedWsProxy.ts` | Port for the embedded-service WebSocket proxy server. | -| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge host (legacy integration). | -| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. | -| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. | -| `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). | +| Variable | Default | Source File | Description | +| ---------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `OPENROUTER_CATALOG_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterCatalog.ts` | OpenRouter model catalog cache TTL. | +| `MODEL_CATALOG_INCLUDE_NAMES` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | Include display-friendly `name` fields in `/v1/models` responses. Disable for clients that expect IDs only. | +| `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | Max wait for NanoBanana image generation jobs. | +| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. | +| `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). | +| `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. | +| `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. | +| `CLOUDFLARE_API_BASE` | `https://api.cloudflare.com/client/v4` | `src/app/api/settings/proxy/cloudflare-deploy/route.ts` | Override the Cloudflare REST API base used by the proxy-pool Workers relay deployer (#4640 / 9router#1360). | +| `NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx` | Default worker project name suggested in the proxy-pool "Deploy Relay" modal. | +| `NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | Set to `false` to hide the Cloudflare Workers relay option from the Proxy Pool tab. | +| `CLOUDFLARED_BIN` | auto-detect | `src/lib/cloudflaredTunnel.ts` | Custom path to `cloudflared` binary. | +| `DENO_DEPLOY_API_BASE` | `https://api.deno.com/v2` | `src/app/api/settings/proxy/deno-deploy/route.ts` | Override the Deno Deploy REST API base used by the proxy-pool relay deployer (#4643 / 9router#1437). | +| `NEXT_PUBLIC_DENO_RELAY_DEFAULT_PROJECT` | `omniroute-deno-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx` | Default Deno Deploy app name suggested in the proxy-pool "Deploy Relay" modal. | +| `NEXT_PUBLIC_DENO_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | Set to `false` to hide the Deno Deploy relay option from the Proxy Pool tab. | +| `SEARCH_CACHE_TTL_MS` | `300000` (5 min) | `open-sse/services/searchCache.ts` | TTL for search API (Perplexity, Brave, etc.) response caching. | +| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | `false` | `src/app/api/providers/route.ts` | Allow multiple simultaneous connections per OpenAI-compatible provider. | +| `ENABLE_CC_COMPATIBLE_PROVIDER` | `false` | `src/shared/utils/featureFlags.ts` | Reveal the experimental CC-compatible provider UI for Claude Code-only relays. | +| `NINEROUTER_HOST` | `127.0.0.1` | `open-sse/executors/ninerouter.ts` | Override the host where the embedded 9router instance listens. | +| `NINEROUTER_PORT` | `20130` | `open-sse/executors/ninerouter.ts` | Override the port where the embedded 9router instance listens. | +| `EMBED_WS_PROXY_HOST` | `127.0.0.1` | `src/lib/services/embedWsProxy.ts` | Bind host for the embedded-service WebSocket proxy (loopback only by default). | +| `EMBED_WS_PROXY_PORT` | `20131` | `src/lib/services/embedWsProxy.ts` | Port for the embedded-service WebSocket proxy server. | +| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge host (legacy integration). | +| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. | +| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. | +| `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). | `ENABLE_CC_COMPATIBLE_PROVIDER` is only for third-party relays that accept Claude Code clients exclusively. OmniRoute rewrites requests so those relays accept them. If you only want to use @@ -780,22 +780,22 @@ Anthropic-compatible provider instead. ## 21. Proxy Health -| Variable | Default | Source File | Description | -| ----------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. | -| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. | -| `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | Allow OAuth and provider validation flows to bypass a pinned proxy and connect directly when proxy reachability pre-checks fail. Effective precedence is Feature Flags DB override > env var > default. | -| `RATE_LIMIT_MAX_WAIT_MS` | `120000` (2 min) | `open-sse/services/rateLimitManager.ts` | Max time to wait on a 429 before failing the request. | -| `RATE_LIMIT_AUTO_ENABLE` | _(unset)_ | `open-sse/services/rateLimitManager.ts` | Force the auto-enable rate limit safety net on/off regardless of the persisted Dashboard setting. Accepts `true`/`1`/`on` to force on, `false`/`0`/`off` to force off. | -| `PROVIDER_COOLDOWN_ENABLED` | _(unset → off)_ | `open-sse/services/providerCooldownTracker.ts` | Opt-in global cross-request provider/connection cooldown tracking. OFF by default (overlaps Connection Cooldown / Provider Circuit Breaker). Accepts `true`/`1`/`on` to enable. | -| `PROVIDER_COOLDOWN_MIN_MS` | `5000` | `open-sse/services/providerCooldownTracker.ts` | Minimum cooldown (ms) before a failed provider/connection is retried. Scaled exponentially with consecutive failures. Only used when `PROVIDER_COOLDOWN_ENABLED`. | -| `PROVIDER_COOLDOWN_MAX_MS` | `300000` (5 min) | `open-sse/services/providerCooldownTracker.ts` | Maximum cooldown (ms) cap before a failed provider/connection is retried regardless. Only used when `PROVIDER_COOLDOWN_ENABLED`. | -| `STREAM_RECOVERY_ENABLED` | _(unset → off)_ | `src/lib/resilience/settings.ts` (seed) → `open-sse/services/streamRecovery.ts` (logic) | **What:** transparent recovery of truncated upstream streams (free-claude-code port). Holds the opening SSE window up to `STREAM_RECOVERY.HOLDBACK_MS` (750 ms) so a _pre-commit_ cutoff — one that happens before any byte reaches the client — is re-opened and retried invisibly. **When to enable:** flaky/upstreams that frequently 0-byte-truncate at stream start; leave OFF if you cannot afford up to 750 ms of added time-to-first-token on every stream. Accepts `true`/`1`/`on`. Seeds the persisted Resilience setting; the Dashboard setting wins once set. | -| `STREAM_RECOVERY_MIDSTREAM_ENABLED` | _(unset → off)_ | `src/lib/resilience/settings.ts` (seed) → `open-sse/services/streamRecovery.ts` (logic) | **What:** mid-stream continuation (Fase 4.4) — after a _post-commit_ truncation (bytes already reached the client), re-request with the partial text as an assistant prefill and stitch the missing suffix. Plain-text OpenAI-compatible streams only; never fires with a tool call in flight. **When to enable:** long generations that get cut mid-answer and you accept the recovered tail arriving as one burst rather than token-by-token. Independent of `STREAM_RECOVERY_ENABLED` (different risk profile). Accepts `true`/`1`/`on`. | -| `HEALTHCHECK_STAGGER_MS` | `3000` | `src/lib/tokenHealthCheck.ts` | Stagger interval (ms) between provider token healthchecks at startup. | -| `REQUEST_RETRY` | `2` | `src/sse/services/cooldownAwareRetry.ts` | Number of automatic retries on model-scoped cooldown responses before returning error to client. | -| `MAX_RETRY_INTERVAL_SEC` | `30` | `src/sse/services/cooldownAwareRetry.ts` | Max backoff interval (seconds) between cooldown retries. Capped by this value regardless of upstream `Retry-After`. | -| `HEADROOM_URL` | `http://localhost:8787` | `src/lib/headroom/detect.ts` | Headroom token-saver proxy URL. The dashboard lifecycle (`api/headroom/*`) spawns a local `headroom-ai` CLI on loopback by default; override only to point at an external Docker sidecar proxy. | +| Variable | Default | Source File | Description | +| ----------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. | +| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. | +| `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | Allow OAuth and provider validation flows to bypass a pinned proxy and connect directly when proxy reachability pre-checks fail. Effective precedence is Feature Flags DB override > env var > default. | +| `RATE_LIMIT_MAX_WAIT_MS` | `120000` (2 min) | `open-sse/services/rateLimitManager.ts` | Max time to wait on a 429 before failing the request. | +| `RATE_LIMIT_AUTO_ENABLE` | _(unset)_ | `open-sse/services/rateLimitManager.ts` | Force the auto-enable rate limit safety net on/off regardless of the persisted Dashboard setting. Accepts `true`/`1`/`on` to force on, `false`/`0`/`off` to force off. | +| `PROVIDER_COOLDOWN_ENABLED` | _(unset → off)_ | `open-sse/services/providerCooldownTracker.ts` | Opt-in global cross-request provider/connection cooldown tracking. OFF by default (overlaps Connection Cooldown / Provider Circuit Breaker). Accepts `true`/`1`/`on` to enable. | +| `PROVIDER_COOLDOWN_MIN_MS` | `5000` | `open-sse/services/providerCooldownTracker.ts` | Minimum cooldown (ms) before a failed provider/connection is retried. Scaled exponentially with consecutive failures. Only used when `PROVIDER_COOLDOWN_ENABLED`. | +| `PROVIDER_COOLDOWN_MAX_MS` | `300000` (5 min) | `open-sse/services/providerCooldownTracker.ts` | Maximum cooldown (ms) cap before a failed provider/connection is retried regardless. Only used when `PROVIDER_COOLDOWN_ENABLED`. | +| `STREAM_RECOVERY_ENABLED` | _(unset → off)_ | `src/lib/resilience/settings.ts` (seed) → `open-sse/services/streamRecovery.ts` (logic) | **What:** transparent recovery of truncated upstream streams (free-claude-code port). Holds the opening SSE window up to `STREAM_RECOVERY.HOLDBACK_MS` (750 ms) so a _pre-commit_ cutoff — one that happens before any byte reaches the client — is re-opened and retried invisibly. **When to enable:** flaky/upstreams that frequently 0-byte-truncate at stream start; leave OFF if you cannot afford up to 750 ms of added time-to-first-token on every stream. Accepts `true`/`1`/`on`. Seeds the persisted Resilience setting; the Dashboard setting wins once set. | +| `STREAM_RECOVERY_MIDSTREAM_ENABLED` | _(unset → off)_ | `src/lib/resilience/settings.ts` (seed) → `open-sse/services/streamRecovery.ts` (logic) | **What:** mid-stream continuation (Fase 4.4) — after a _post-commit_ truncation (bytes already reached the client), re-request with the partial text as an assistant prefill and stitch the missing suffix. Plain-text OpenAI-compatible streams only; never fires with a tool call in flight. **When to enable:** long generations that get cut mid-answer and you accept the recovered tail arriving as one burst rather than token-by-token. Independent of `STREAM_RECOVERY_ENABLED` (different risk profile). Accepts `true`/`1`/`on`. | +| `HEALTHCHECK_STAGGER_MS` | `3000` | `src/lib/tokenHealthCheck.ts` | Stagger interval (ms) between provider token healthchecks at startup. | +| `REQUEST_RETRY` | `2` | `src/sse/services/cooldownAwareRetry.ts` | Number of automatic retries on model-scoped cooldown responses before returning error to client. | +| `MAX_RETRY_INTERVAL_SEC` | `30` | `src/sse/services/cooldownAwareRetry.ts` | Max backoff interval (seconds) between cooldown retries. Capped by this value regardless of upstream `Retry-After`. | +| `HEADROOM_URL` | `http://localhost:8787` | `src/lib/headroom/detect.ts` | Headroom token-saver proxy URL. The dashboard lifecycle (`api/headroom/*`) spawns a local `headroom-ai` CLI on loopback by default; override only to point at an external Docker sidecar proxy. | ### Stream-recovery tuning constants (not env vars) @@ -938,83 +938,83 @@ Limits and safety knobs applied when the Skills framework (`src/lib/skills/`) ex Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), the 1Proxy egress pool, database backups and small per-feature overrides referenced by the executor layer or scripts. -| Variable | Default | Source File | Description | -| ------------------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `REDIS_URL` | `redis://localhost:6379` | `src/shared/utils/rateLimiter.ts` | Redis connection string for the rate limiter backend. | -| `ALIBABA_CODING_PLAN_HOST` | _(production host)_ | `open-sse/services/bailianQuotaFetcher.ts` | Override the host used to fetch Alibaba Bailian coding-plan quotas. | -| `ALIBABA_CODING_PLAN_QUOTA_URL` | derived from host | `open-sse/services/bailianQuotaFetcher.ts` | Full quota URL override for Alibaba Bailian. | -| `CONTEXT_RESERVE_TOKENS` | `1024` | `open-sse/services/contextManager.ts` | Tokens reserved for completion output when computing prompt budgets. | -| `MODEL_ALIAS_COMPAT_ENABLED` | enabled | `open-sse/services/model.ts` | Toggle the legacy model-alias compatibility layer used by older clients. | -| `OMNIROUTE_EMERGENCY_FALLBACK` | enabled | `open-sse/services/emergencyFallback.ts` | Set `false` (or `0`) to disable the emergency budget-exhaustion fallback that reroutes failed requests to the free `nvidia`/`openai/gpt-oss-120b` model. Effective precedence is Feature Flags DB override > env var > default; if unavailable, the service falls back to the raw env value. | -| `COMMAND_CODE_CALLBACK_PORT` | _(unset)_ | `src/app/api/providers/command-code/auth/shared.ts` | Local port used for OAuth-style callbacks from the Command Code CLI helper. | -| `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | Value sent as the `x-command-code-version` header to the Command Code upstream. Override to bump the CLI version. | -| `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | Local bind port for the MITM debug proxy. | -| `MITM_DISABLE_TLS_VERIFY` | `0` | `src/mitm/server.cjs` | Set `1` to disable upstream TLS verification (development only). | -| `MITM_IDLE_TIMEOUT_MS` | `60000` | `src/mitm/socketTimeouts.ts`, `src/mitm/server.cjs` | Idle socket timeout (ms) for proxied connections; idle sockets past this are torn down to avoid leaking half-open tunnels. | -| `MITM_VERBOSE` | `1` | `src/mitm/server.cjs`, `src/mitm/_internal/bypass.cjs` | Routing-decision log verbosity: `0` silences, higher values log more bypass/route decisions. | -| `ONEPROXY_ENABLED` | `true` | `src/lib/oneproxySync.ts` | Enable the 1Proxy egress pool sync. | -| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | `src/lib/oneproxySync.ts` | 1Proxy service API URL override. | -| `ONEPROXY_MAX_PROXIES` | `500` | `src/lib/oneproxySync.ts` | Maximum proxies imported per sync. | -| `ONEPROXY_MIN_QUALITY_THRESHOLD` | `50` | `src/lib/oneproxySync.ts` | Minimum quality score for imported proxies. | -| `FREE_PROXY_1PROXY_ENABLED` | `true` | `src/lib/freeProxyProviders/oneproxy.ts` | Enable the 1proxy free proxy source. Set to `false` to disable. | -| `FREE_PROXY_1PROXY_API_URL` | _(see oneproxy.ts)_ | `src/lib/freeProxyProviders/oneproxy.ts` | 1proxy API URL override. | -| `FREE_PROXY_1PROXY_MAX` | `500` | `src/lib/freeProxyProviders/oneproxy.ts` | Maximum proxies fetched per sync from 1proxy. | -| `FREE_PROXY_1PROXY_MIN_QUALITY` | `50` | `src/lib/freeProxyProviders/oneproxy.ts` | Minimum quality score threshold for 1proxy imports. | -| `FREE_PROXY_PROXIFLY_ENABLED` | `true` | `src/lib/freeProxyProviders/proxifly.ts` | Enable the Proxifly free proxy source. Set to `false` to disable. | -| `FREE_PROXY_PROXIFLY_QUANTITY` | `100` | `src/lib/freeProxyProviders/proxifly.ts` | Number of proxies to fetch per Proxifly sync. | -| `FREE_PROXY_PROXIFLY_ANONYMITY` | `elite` | `src/lib/freeProxyProviders/proxifly.ts` | Anonymity level filter for Proxifly (`elite`, `anonymous`, `transparent`). | -| `FREE_PROXY_IPLOCATE_ENABLED` | `false` | `src/lib/freeProxyProviders/iplocate.ts` | Enable the IPLocate free proxy source. Opt-in only. | -| `FREE_PROXY_IPLOCATE_BASE_URL` | `https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols` | `src/lib/freeProxyProviders/iplocate.ts` | IPLocate proxy list base URL override. | -| `NEXT_PUBLIC_VERCEL_RELAY_ENABLED` | `true` | `src/app/(dashboard)/…/ProxyPoolTab.tsx` | Show/hide the Deploy Vercel Relay button in the Proxy Pool tab. | -| `VERCEL_API_BASE` | `https://api.vercel.com` | `src/app/api/settings/proxy/vercel-deploy/route.ts` | Vercel API base URL override (for testing). | -| `NEXT_PUBLIC_VERCEL_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/…/VercelRelayModal.tsx` | Default project name pre-filled in the Vercel Relay deploy modal. | -| `TAILSCALE_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscale` binary. | -| `TAILSCALED_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscaled` daemon binary. | -| `TAILSCALE_AUTHKEY` | _(unset)_ | `src/lib/tailscaleTunnel.ts` | Pre-shared Tailscale auth key for non-interactive / headless `tailscale up` (passed via `--auth-key=`). When unset, login falls back to the interactive browser auth URL. | -| `NGROK_AUTHTOKEN` | _(unset)_ | `src/lib/ngrokTunnel.ts` | Authenticates outbound ngrok tunnels. | -| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | Maximum SQLite backup files retained on disk. Overrides the value saved from Settings → Database backup retention. | -| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. Overrides the value saved from Settings → Database backup retention. | -| `OMNIROUTE_TLS_PROXY_URL` | _(unset)_ | `open-sse/services/chatgptTlsClient.ts` | Override the TLS sidecar URL for tests. Production should leave unset. | -| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | Container runtime hint for the entrypoint permission check. Set to `podman` under rootless Podman so the fix instructions use `podman unshare chown` instead of `sudo chown`. | -| `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | Quota-share consumption store backend: `sqlite` (default) or `redis`. | -| `QUOTA_STORE_REDIS_URL` | _(unset)_ | `src/lib/quota/storeFactory.ts` | Redis connection string used when `QUOTA_STORE_DRIVER=redis` (e.g. `redis://localhost:6379`). | -| `QUOTA_SATURATION_THRESHOLD` | `0.5` | `src/lib/quota/enforce.ts` | Pool saturation ratio (0..1); at/above it the pool enters strict mode (no borrowing). | -| `QUOTA_SOFT_DEPRIORITIZE_FACTOR` | `0.7` | `open-sse/services/combo.ts` | Score multiplier (0..1) applied to a target when the soft quota policy deprioritizes it. | -| `STATUS_SOFT_DEPRIORITIZE_FACTOR` | `0.5` | `open-sse/services/combo/autoStrategy.ts` | Score multiplier (0..1) applied to an exhausted provider (`credits_exhausted`/`rate_limited`) in auto-combo scoring when the preflight quota cutoff is OFF (#4540). | -| `QUOTA_CONSUMPTION_RETENTION_DAYS` | `14` | `src/lib/db/quotaConsumption.ts` | Retention window (days) for `quota_consumption` buckets before GC (`gcQuotaConsumption`). | -| `QUOTA_PREFLIGHT_CUTOFF_ENABLED` | `false` | `src/lib/resilience/settings.ts` | Opt-in (default OFF): enables the auto-routing hard quota cutoff that drops low-quota candidates before scoring. | -| `OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL` | `false` | `open-sse/services/autoCombo/virtualFactory.ts` | Opt-in (default OFF): when an `auto/:` filter matches no connected candidates, restore the legacy behavior of falling back to the full (unfiltered) pool instead of returning an empty pool. Default OFF makes `:free` mean "free tier only". | -| `AGENTBRIDGE_UPSTREAM_CA_CERT` | _(unset)_ | `src/mitm/manager.ts` | Extra CA certificate (PEM) trusted for AgentBridge upstream TLS connections. | -| `INSPECTOR_BUFFER_SIZE` | `1000` | `src/mitm/inspector/buffer.ts` | Max captured requests held in the Traffic Inspector ring buffer. | -| `INSPECTOR_MAX_BODY_KB` | `1024` | `src/mitm/inspector/buffer.ts` | Max captured request/response body size (KB) before truncation. | -| `INSPECTOR_HTTP_PROXY_PORT` | `8080` | `src/mitm/inspector/httpProxyServer.ts` | Local port for the Traffic Inspector HTTP proxy. | -| `INSPECTOR_HTTP_PROXY_AUTOSTART` | `false` | `src/mitm/inspector/httpProxyServer.ts` | Auto-start the inspector HTTP proxy on boot. | -| `INSPECTOR_TLS_INTERCEPT` | `false` | `src/lib/inspector/captureState.ts` | Enable TLS interception (MITM) for captured HTTPS traffic. | -| `INSPECTOR_LLM_HOSTS_EXTRA` | _(unset)_ | `src/lib/inspector/captureState.ts` | Extra hostnames (comma-separated) treated as LLM endpoints for capture. | -| `INSPECTOR_MASK_SECRETS` | `true` | `src/mitm/inspector/buffer.ts` | Mask secrets (auth headers / API keys) in captured traffic. | -| `INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES` | `30` | `src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts` | Minutes before the system-proxy guard auto-reverts OS proxy settings. | -| `INSPECTOR_INTERNAL_INGEST_TOKEN` | _(auto)_ | `src/app/api/tools/traffic-inspector/internal/ingest/route.ts` | Token authenticating internal capture ingest into the inspector. | -| `PLAYGROUND_COMPARE_MAX_COLUMNS` | `4` | `src/app/(dashboard)/dashboard/playground/` | Max number of side-by-side columns in the Playground compare mode. | -| `PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL` | _(unset)_ | `src/app/(dashboard)/dashboard/playground/` | Default model for the Playground 'improve prompt' action (falls back to the active model when unset). | -| `BIFROST_ENABLED` | `1` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Master kill switch for the bifrost sidecar proxy. When set to `0`, the route returns 503 with the `X-Bifrost-Killswitch` header and the operator is bounced to the TS path. Use to disable the sidecar without redeploying (tier-1 router incident, key rotation). | -| `BIFROST_BASE_URL` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When set, the Bifrost sidecar proxy route forwards `/v1/chat/completions` traffic to this Go gateway instead of the TS relay handler. Unset → 503-with-fallback. Trailing slash is stripped. | -| `BIFROST_API_KEY` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | API key for the Bifrost gateway (sent as `Authorization: Bearer ...`). If unset, the route expects the request to carry a valid OmniRoute API key; this key is for gateway-side auth only. | -| `BIFROST_STREAMING_ENABLED` | `true` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When true, the Bifrost sidecar route streams responses back via SSE through the gateway rather than the TS streaming executor. Set to `0` to force non-streaming JSON responses through the gateway. | -| `BIFROST_TIMEOUT_MS` | `30000` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Per-request timeout when proxying to the Bifrost gateway (ms). On timeout the route returns the TS relay path via the `X-Bifrost-Fallback` header. | -| `OMNIROUTE_BIFROST_KEY` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Alias for `BIFROST_API_KEY` (used by scripts that read the env via `OMNIROUTE_*`). Falls back to `BIFROST_API_KEY` when unset. | -| `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED` | `0` | `src/lib/security/localEndpoints.ts` | Master switch for `/api/local/*` routes. When unset or `0`, all `/api/local/*` routes return 503 in production. Must be `1` in non-loopback deploys to enable the Redis launcher and similar 1-click local service starters. Belt-and-suspenders with `isLocalOnlyPath()` route-guard classification (`LOCAL_ONLY_API_PREFIXES` in `src/server/authz/routeGuard.ts`). | -| `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(unset)_ | `src/lib/security/localEndpoints.ts` | Bearer token for `/api/local/*` callers that aren't on loopback (e.g. the desktop app). When set, requests from non-loopback IPs must carry `Authorization: Bearer `. Required when `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` in non-loopback deployments. | -| `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | Container name for the 1-click Redis launcher (`omniroute redis up`). Used by both the CLI and the `RedisLauncherPanel` GUI. | -| `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | Host port for the 1-click Redis launcher. Bump if the host already binds 6379. The container's internal port stays 6379. | -| `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | Redis image used by the 1-click Redis launcher. Override to `redis:8-alpine` or a private registry mirror as needed. | -| `QDRANT_HOST` | `qdrant` | _(opt-in cluster profile)_ | Hostname of the Qdrant sidecar when `--profile memory` is active. Default points to the in-network qdrant service name; override for an external deployment. Only consumed when `qdrantEnabled` is `true` in code (`src/lib/memory/vectorStore.ts:108`). | -| `QDRANT_PORT` | `6333` | _(opt-in cluster profile)_ | REST port of the Qdrant sidecar. | -| `QDRANT_GRPC_PORT` | `6334` | _(opt-in cluster profile)_ | gRPC port of the Qdrant sidecar. Used by client libraries that prefer gRPC over REST for streaming ops. | -| `QDRANT_API_KEY` | _(unset)_ | _(opt-in cluster profile)_ | Optional API key for Qdrant Cloud or an authenticated on-prem instance. Empty → no `api-key` header sent. | -| `QDRANT_COLLECTION` | `omniroute-memory` | _(opt-in cluster profile)_ | Collection name for OmniRoute's conversation memory embeddings. Created on first run with `QDRANT_VECTOR_SIZE` dimensions. | -| `QDRANT_EMBEDDING_MODEL` | `text-embedding-3-small` | _(opt-in cluster profile)_ | Default embedding model name recorded in the Qdrant collection metadata. Actual embeddings are generated by whatever provider the `embeddingModel` field in OmniRoute's settings points to. | -| `QDRANT_VECTOR_SIZE` | `1536` | _(opt-in cluster profile)_ | Embedding vector dimension. Must match the model you embed with (text-embedding-3-small → 1536; ada-002 → 1536; nomic-embed-text → 768). | -| `QDRANT_HNSW_EF_CONSTRUCT` | `128` | _(opt-in cluster profile)_ | HNSW index construction-time accuracy. Higher = slower build, faster search. | +| Variable | Default | Source File | Description | +| ------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `REDIS_URL` | `redis://localhost:6379` | `src/shared/utils/rateLimiter.ts` | Redis connection string for the rate limiter backend. | +| `ALIBABA_CODING_PLAN_HOST` | _(production host)_ | `open-sse/services/bailianQuotaFetcher.ts` | Override the host used to fetch Alibaba Bailian coding-plan quotas. | +| `ALIBABA_CODING_PLAN_QUOTA_URL` | derived from host | `open-sse/services/bailianQuotaFetcher.ts` | Full quota URL override for Alibaba Bailian. | +| `CONTEXT_RESERVE_TOKENS` | `1024` | `open-sse/services/contextManager.ts` | Tokens reserved for completion output when computing prompt budgets. | +| `MODEL_ALIAS_COMPAT_ENABLED` | enabled | `open-sse/services/model.ts` | Toggle the legacy model-alias compatibility layer used by older clients. | +| `OMNIROUTE_EMERGENCY_FALLBACK` | enabled | `open-sse/services/emergencyFallback.ts` | Set `false` (or `0`) to disable the emergency budget-exhaustion fallback that reroutes failed requests to the free `nvidia`/`openai/gpt-oss-120b` model. Effective precedence is Feature Flags DB override > env var > default; if unavailable, the service falls back to the raw env value. | +| `COMMAND_CODE_CALLBACK_PORT` | _(unset)_ | `src/app/api/providers/command-code/auth/shared.ts` | Local port used for OAuth-style callbacks from the Command Code CLI helper. | +| `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | Value sent as the `x-command-code-version` header to the Command Code upstream. Override to bump the CLI version. | +| `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | Local bind port for the MITM debug proxy. | +| `MITM_DISABLE_TLS_VERIFY` | `0` | `src/mitm/server.cjs` | Set `1` to disable upstream TLS verification (development only). | +| `MITM_IDLE_TIMEOUT_MS` | `60000` | `src/mitm/socketTimeouts.ts`, `src/mitm/server.cjs` | Idle socket timeout (ms) for proxied connections; idle sockets past this are torn down to avoid leaking half-open tunnels. | +| `MITM_VERBOSE` | `1` | `src/mitm/server.cjs`, `src/mitm/_internal/bypass.cjs` | Routing-decision log verbosity: `0` silences, higher values log more bypass/route decisions. | +| `ONEPROXY_ENABLED` | `true` | `src/lib/oneproxySync.ts` | Enable the 1Proxy egress pool sync. | +| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | `src/lib/oneproxySync.ts` | 1Proxy service API URL override. | +| `ONEPROXY_MAX_PROXIES` | `500` | `src/lib/oneproxySync.ts` | Maximum proxies imported per sync. | +| `ONEPROXY_MIN_QUALITY_THRESHOLD` | `50` | `src/lib/oneproxySync.ts` | Minimum quality score for imported proxies. | +| `FREE_PROXY_1PROXY_ENABLED` | `true` | `src/lib/freeProxyProviders/oneproxy.ts` | Enable the 1proxy free proxy source. Set to `false` to disable. | +| `FREE_PROXY_1PROXY_API_URL` | _(see oneproxy.ts)_ | `src/lib/freeProxyProviders/oneproxy.ts` | 1proxy API URL override. | +| `FREE_PROXY_1PROXY_MAX` | `500` | `src/lib/freeProxyProviders/oneproxy.ts` | Maximum proxies fetched per sync from 1proxy. | +| `FREE_PROXY_1PROXY_MIN_QUALITY` | `50` | `src/lib/freeProxyProviders/oneproxy.ts` | Minimum quality score threshold for 1proxy imports. | +| `FREE_PROXY_PROXIFLY_ENABLED` | `true` | `src/lib/freeProxyProviders/proxifly.ts` | Enable the Proxifly free proxy source. Set to `false` to disable. | +| `FREE_PROXY_PROXIFLY_QUANTITY` | `100` | `src/lib/freeProxyProviders/proxifly.ts` | Number of proxies to fetch per Proxifly sync. | +| `FREE_PROXY_PROXIFLY_ANONYMITY` | `elite` | `src/lib/freeProxyProviders/proxifly.ts` | Anonymity level filter for Proxifly (`elite`, `anonymous`, `transparent`). | +| `FREE_PROXY_IPLOCATE_ENABLED` | `false` | `src/lib/freeProxyProviders/iplocate.ts` | Enable the IPLocate free proxy source. Opt-in only. | +| `FREE_PROXY_IPLOCATE_BASE_URL` | `https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols` | `src/lib/freeProxyProviders/iplocate.ts` | IPLocate proxy list base URL override. | +| `NEXT_PUBLIC_VERCEL_RELAY_ENABLED` | `true` | `src/app/(dashboard)/…/ProxyPoolTab.tsx` | Show/hide the Deploy Vercel Relay button in the Proxy Pool tab. | +| `VERCEL_API_BASE` | `https://api.vercel.com` | `src/app/api/settings/proxy/vercel-deploy/route.ts` | Vercel API base URL override (for testing). | +| `NEXT_PUBLIC_VERCEL_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/…/VercelRelayModal.tsx` | Default project name pre-filled in the Vercel Relay deploy modal. | +| `TAILSCALE_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscale` binary. | +| `TAILSCALED_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscaled` daemon binary. | +| `TAILSCALE_AUTHKEY` | _(unset)_ | `src/lib/tailscaleTunnel.ts` | Pre-shared Tailscale auth key for non-interactive / headless `tailscale up` (passed via `--auth-key=`). When unset, login falls back to the interactive browser auth URL. | +| `NGROK_AUTHTOKEN` | _(unset)_ | `src/lib/ngrokTunnel.ts` | Authenticates outbound ngrok tunnels. | +| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | Maximum SQLite backup files retained on disk. Overrides the value saved from Settings → Database backup retention. | +| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. Overrides the value saved from Settings → Database backup retention. | +| `OMNIROUTE_TLS_PROXY_URL` | _(unset)_ | `open-sse/services/chatgptTlsClient.ts` | Override the TLS sidecar URL for tests. Production should leave unset. | +| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | Container runtime hint for the entrypoint permission check. Set to `podman` under rootless Podman so the fix instructions use `podman unshare chown` instead of `sudo chown`. | +| `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | Quota-share consumption store backend: `sqlite` (default) or `redis`. | +| `QUOTA_STORE_REDIS_URL` | _(unset)_ | `src/lib/quota/storeFactory.ts` | Redis connection string used when `QUOTA_STORE_DRIVER=redis` (e.g. `redis://localhost:6379`). | +| `QUOTA_SATURATION_THRESHOLD` | `0.5` | `src/lib/quota/enforce.ts` | Pool saturation ratio (0..1); at/above it the pool enters strict mode (no borrowing). | +| `QUOTA_SOFT_DEPRIORITIZE_FACTOR` | `0.7` | `open-sse/services/combo.ts` | Score multiplier (0..1) applied to a target when the soft quota policy deprioritizes it. | +| `STATUS_SOFT_DEPRIORITIZE_FACTOR` | `0.5` | `open-sse/services/combo/autoStrategy.ts` | Score multiplier (0..1) applied to an exhausted provider (`credits_exhausted`/`rate_limited`) in auto-combo scoring when the preflight quota cutoff is OFF (#4540). | +| `QUOTA_CONSUMPTION_RETENTION_DAYS` | `14` | `src/lib/db/quotaConsumption.ts` | Retention window (days) for `quota_consumption` buckets before GC (`gcQuotaConsumption`). | +| `QUOTA_PREFLIGHT_CUTOFF_ENABLED` | `false` | `src/lib/resilience/settings.ts` | Opt-in (default OFF): enables the auto-routing hard quota cutoff that drops low-quota candidates before scoring. | +| `OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL` | `false` | `open-sse/services/autoCombo/virtualFactory.ts` | Opt-in (default OFF): when an `auto/:` filter matches no connected candidates, restore the legacy behavior of falling back to the full (unfiltered) pool instead of returning an empty pool. Default OFF makes `:free` mean "free tier only". | +| `AGENTBRIDGE_UPSTREAM_CA_CERT` | _(unset)_ | `src/mitm/manager.ts` | Extra CA certificate (PEM) trusted for AgentBridge upstream TLS connections. | +| `INSPECTOR_BUFFER_SIZE` | `1000` | `src/mitm/inspector/buffer.ts` | Max captured requests held in the Traffic Inspector ring buffer. | +| `INSPECTOR_MAX_BODY_KB` | `1024` | `src/mitm/inspector/buffer.ts` | Max captured request/response body size (KB) before truncation. | +| `INSPECTOR_HTTP_PROXY_PORT` | `8080` | `src/mitm/inspector/httpProxyServer.ts` | Local port for the Traffic Inspector HTTP proxy. | +| `INSPECTOR_HTTP_PROXY_AUTOSTART` | `false` | `src/mitm/inspector/httpProxyServer.ts` | Auto-start the inspector HTTP proxy on boot. | +| `INSPECTOR_TLS_INTERCEPT` | `false` | `src/lib/inspector/captureState.ts` | Enable TLS interception (MITM) for captured HTTPS traffic. | +| `INSPECTOR_LLM_HOSTS_EXTRA` | _(unset)_ | `src/lib/inspector/captureState.ts` | Extra hostnames (comma-separated) treated as LLM endpoints for capture. | +| `INSPECTOR_MASK_SECRETS` | `true` | `src/mitm/inspector/buffer.ts` | Mask secrets (auth headers / API keys) in captured traffic. | +| `INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES` | `30` | `src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts` | Minutes before the system-proxy guard auto-reverts OS proxy settings. | +| `INSPECTOR_INTERNAL_INGEST_TOKEN` | _(auto)_ | `src/app/api/tools/traffic-inspector/internal/ingest/route.ts` | Token authenticating internal capture ingest into the inspector. | +| `PLAYGROUND_COMPARE_MAX_COLUMNS` | `4` | `src/app/(dashboard)/dashboard/playground/` | Max number of side-by-side columns in the Playground compare mode. | +| `PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL` | _(unset)_ | `src/app/(dashboard)/dashboard/playground/` | Default model for the Playground 'improve prompt' action (falls back to the active model when unset). | +| `BIFROST_ENABLED` | `1` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Master kill switch for the bifrost sidecar proxy. When set to `0`, the route returns 503 with the `X-Bifrost-Killswitch` header and the operator is bounced to the TS path. Use to disable the sidecar without redeploying (tier-1 router incident, key rotation). | +| `BIFROST_BASE_URL` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When set, the Bifrost sidecar proxy route forwards `/v1/chat/completions` traffic to this Go gateway instead of the TS relay handler. Unset → 503-with-fallback. Trailing slash is stripped. | +| `BIFROST_API_KEY` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | API key for the Bifrost gateway (sent as `Authorization: Bearer ...`). If unset, the route expects the request to carry a valid OmniRoute API key; this key is for gateway-side auth only. | +| `BIFROST_STREAMING_ENABLED` | `true` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When true, the Bifrost sidecar route streams responses back via SSE through the gateway rather than the TS streaming executor. Set to `0` to force non-streaming JSON responses through the gateway. | +| `BIFROST_TIMEOUT_MS` | `30000` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Per-request timeout when proxying to the Bifrost gateway (ms). On timeout the route returns the TS relay path via the `X-Bifrost-Fallback` header. | +| `OMNIROUTE_BIFROST_KEY` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Alias for `BIFROST_API_KEY` (used by scripts that read the env via `OMNIROUTE_*`). Falls back to `BIFROST_API_KEY` when unset. | +| `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED` | `0` | `src/lib/security/localEndpoints.ts` | Master switch for `/api/local/*` routes. When unset or `0`, all `/api/local/*` routes return 503 in production. Must be `1` in non-loopback deploys to enable the Redis launcher and similar 1-click local service starters. Belt-and-suspenders with `isLocalOnlyPath()` route-guard classification (`LOCAL_ONLY_API_PREFIXES` in `src/server/authz/routeGuard.ts`). | +| `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(unset)_ | `src/lib/security/localEndpoints.ts` | Bearer token for `/api/local/*` callers that aren't on loopback (e.g. the desktop app). When set, requests from non-loopback IPs must carry `Authorization: Bearer `. Required when `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` in non-loopback deployments. | +| `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | Container name for the 1-click Redis launcher (`omniroute redis up`). Used by both the CLI and the `RedisLauncherPanel` GUI. | +| `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | Host port for the 1-click Redis launcher. Bump if the host already binds 6379. The container's internal port stays 6379. | +| `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | Redis image used by the 1-click Redis launcher. Override to `redis:8-alpine` or a private registry mirror as needed. | +| `QDRANT_HOST` | `qdrant` | _(opt-in cluster profile)_ | Hostname of the Qdrant sidecar when `--profile memory` is active. Default points to the in-network qdrant service name; override for an external deployment. Only consumed when `qdrantEnabled` is `true` in code (`src/lib/memory/vectorStore.ts:108`). | +| `QDRANT_PORT` | `6333` | _(opt-in cluster profile)_ | REST port of the Qdrant sidecar. | +| `QDRANT_GRPC_PORT` | `6334` | _(opt-in cluster profile)_ | gRPC port of the Qdrant sidecar. Used by client libraries that prefer gRPC over REST for streaming ops. | +| `QDRANT_API_KEY` | _(unset)_ | _(opt-in cluster profile)_ | Optional API key for Qdrant Cloud or an authenticated on-prem instance. Empty → no `api-key` header sent. | +| `QDRANT_COLLECTION` | `omniroute-memory` | _(opt-in cluster profile)_ | Collection name for OmniRoute's conversation memory embeddings. Created on first run with `QDRANT_VECTOR_SIZE` dimensions. | +| `QDRANT_EMBEDDING_MODEL` | `text-embedding-3-small` | _(opt-in cluster profile)_ | Default embedding model name recorded in the Qdrant collection metadata. Actual embeddings are generated by whatever provider the `embeddingModel` field in OmniRoute's settings points to. | +| `QDRANT_VECTOR_SIZE` | `1536` | _(opt-in cluster profile)_ | Embedding vector dimension. Must match the model you embed with (text-embedding-3-small → 1536; ada-002 → 1536; nomic-embed-text → 768). | +| `QDRANT_HNSW_EF_CONSTRUCT` | `128` | _(opt-in cluster profile)_ | HNSW index construction-time accuracy. Higher = slower build, faster search. | --- @@ -1103,6 +1103,6 @@ is developer tooling only. Used by `scripts/compression-eval/index.ts`, the offline compression evaluation CLI. Not required for normal operation — developer tooling only. -| Variable | Default | Source File | Description | -| --------------------------- | ------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Variable | Default | Source File | Description | +| ---------------------------- | ------------ | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `OMNIROUTE_EVAL_CREDENTIALS` | `{}` (empty) | `scripts/compression-eval/index.ts` | Operator-supplied JSON credentials for the provider exercised by the offline compression-eval CLI (parsed with `JSON.parse`). Leave unset for a dry run. | diff --git a/docs/security/STEALTH_GUIDE.md b/docs/security/STEALTH_GUIDE.md index 358152b30e95..25e2080bb1f3 100644 --- a/docs/security/STEALTH_GUIDE.md +++ b/docs/security/STEALTH_GUIDE.md @@ -88,7 +88,7 @@ Applied to: `system` blocks, all `messages[].content`, and `tools[].description` For third-party Anthropic relays that only accept "real Claude Code" traffic: -- `CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.187 (external, sdk-cli)"` +- `CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.195 (external, sdk-cli)"` - `CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.94.0"` - `CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0"` - `anthropic-beta = "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24"` by default @@ -214,13 +214,13 @@ All MITM endpoints require management auth (`requireCliToolsAuth`). The sudo pas | Variable | Default | | ------------------------ | --------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.187 (external, cli)` | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.195 (external, cli)` | | `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | -| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | +| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` | | `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | | `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | | `QODER_USER_AGENT` | `Qoder-Cli` | -| `QWEN_USER_AGENT` | `QwenCode/0.15.9 (linux; x64)` | +| `QWEN_USER_AGENT` | `QwenCode/0.19.3 (linux; x64)` | | `CURSOR_USER_AGENT` | `Cursor/3.3` | | `GEMINI_CLI_USER_AGENT` | `google-api-nodejs-client/10.3.0` | diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index 37a7c5ce6e92..d378407e8da7 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -50,11 +50,17 @@ export function mergeClientAnthropicBeta( clientBeta: string | null | undefined, allow: readonly string[] = FORWARDABLE_CLIENT_BETAS ): string { - const baseList = base.split(",").map((s) => s.trim()).filter(Boolean); + const baseList = base + .split(",") + .map((s) => s.trim()) + .filter(Boolean); if (typeof clientBeta !== "string" || !clientBeta.trim()) return baseList.join(","); const seen = new Set(baseList.map((s) => s.toLowerCase())); const allowSet = new Set(allow.map((s) => s.toLowerCase())); - for (const token of clientBeta.split(",").map((s) => s.trim()).filter(Boolean)) { + for (const token of clientBeta + .split(",") + .map((s) => s.trim()) + .filter(Boolean)) { const lower = token.toLowerCase(); if (allowSet.has(lower) && !seen.has(lower)) { baseList.push(token); @@ -115,7 +121,7 @@ export function normalizeAnthropicHeaderVariants(headers: Record } } -export const CLAUDE_CLI_VERSION = "2.1.187"; +export const CLAUDE_CLI_VERSION = "2.1.195"; export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`; export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.94.0"; export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = "v24.3.0"; diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts index 46803d4ec939..4f204730806b 100644 --- a/open-sse/config/glmProvider.ts +++ b/open-sse/config/glmProvider.ts @@ -150,7 +150,7 @@ export const GLMT_REQUEST_DEFAULTS = Object.freeze({ }); export const GLM_COUNT_TOKENS_TIMEOUT_MS = 3_000; -export const GLM_CLAUDE_CODE_USER_AGENT = "claude-cli/2.1.137 (external, sdk-cli)"; +export const GLM_CLAUDE_CODE_USER_AGENT = "claude-cli/2.1.195 (external, sdk-cli)"; export const GLM_ANTHROPIC_BETA = [ "claude-code-20250219", "interleaved-thinking-2025-05-14", diff --git a/open-sse/config/providerHeaderProfiles.ts b/open-sse/config/providerHeaderProfiles.ts index 2e587500553f..59dc53aa8ff8 100644 --- a/open-sse/config/providerHeaderProfiles.ts +++ b/open-sse/config/providerHeaderProfiles.ts @@ -1,9 +1,9 @@ import { antigravityUserAgent } from "../services/antigravityHeaders.ts"; -export const GITHUB_COPILOT_API_VERSION = "2025-04-01"; -export const GITHUB_COPILOT_EDITOR_VERSION = "vscode/1.117.0"; -export const GITHUB_COPILOT_CHAT_PLUGIN_VERSION = "copilot-chat/0.45.1"; -export const GITHUB_COPILOT_CHAT_USER_AGENT = "GitHubCopilotChat/0.45.1"; +export const GITHUB_COPILOT_API_VERSION = "2026-06-01"; +export const GITHUB_COPILOT_EDITOR_VERSION = "vscode/1.126.0"; +export const GITHUB_COPILOT_CHAT_PLUGIN_VERSION = "copilot-chat/0.54.0"; +export const GITHUB_COPILOT_CHAT_USER_AGENT = "GitHubCopilotChat/0.54.0"; export const GITHUB_COPILOT_REFRESH_PLUGIN_VERSION = "copilot/1.388.0"; export const GITHUB_COPILOT_REFRESH_USER_AGENT = "GithubCopilot/1.0"; export const GITHUB_COPILOT_INTEGRATION_ID = "vscode-chat"; @@ -11,7 +11,7 @@ export const GITHUB_COPILOT_OPENAI_INTENT = "conversation-panel"; export const GITHUB_COPILOT_DEFAULT_INITIATOR = "user"; export const GITHUB_COPILOT_USER_AGENT_LIBRARY = "electron-fetch"; -export const QWEN_CLI_VERSION = "0.15.9"; +export const QWEN_CLI_VERSION = "0.19.3"; export const QWEN_STAINLESS_LANG = "js"; export const QWEN_STAINLESS_PACKAGE_VERSION = "5.11.0"; export const QWEN_STAINLESS_RETRY_COUNT = "1"; @@ -26,7 +26,7 @@ export const KIRO_AMZ_USER_AGENT = "aws-sdk-js/3.0.0 kiro-ide/1.0.0"; export const KIRO_STREAMING_TARGET = "AmazonCodeWhispererStreamingService.GenerateAssistantResponse"; -export const CURSOR_REGISTRY_VERSION = "3.3"; +export const CURSOR_REGISTRY_VERSION = "3.9"; export function getGitHubCopilotChatHeaders( accept = "application/json", diff --git a/open-sse/executors/adapta-web.ts b/open-sse/executors/adapta-web.ts index b94f3cfcae93..8c9ae571ea50 100644 --- a/open-sse/executors/adapta-web.ts +++ b/open-sse/executors/adapta-web.ts @@ -23,7 +23,7 @@ const MODEL_ID_MAP: Record = { }; const USER_AGENT = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; // ── In-memory session cache ─────────────────────────────────────────────────── @@ -492,31 +492,49 @@ export class AdaptaWebExecutor extends BaseExecutor { } if (hasTools) { - const { content, toolCalls, finishReason } = buildToolAwareResult(fullText, requestedTools, "adp"); + const { content, toolCalls, finishReason } = buildToolAwareResult( + fullText, + requestedTools, + "adp" + ); if (toolCalls) { return { response: new Response( JSON.stringify({ - id: `chatcmpl-adp-${Date.now()}`, object: "chat.completion", - created: Math.floor(Date.now() / 1000), model, - choices: [{ index: 0, message: { role: "assistant", content: null, tool_calls: toolCalls }, finish_reason: finishReason }], + id: `chatcmpl-adp-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: null, tool_calls: toolCalls }, + finish_reason: finishReason, + }, + ], }), { status: 200, headers: { "Content-Type": "application/json" } } ), - url: ADAPTA_STREAM_URL, headers, transformedBody: requestPayload, + url: ADAPTA_STREAM_URL, + headers, + transformedBody: requestPayload, }; } return { response: new Response( JSON.stringify({ - id: `chatcmpl-adp-${Date.now()}`, object: "chat.completion", - created: Math.floor(Date.now() / 1000), model, + id: `chatcmpl-adp-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, }), { status: 200, headers: { "Content-Type": "application/json" } } ), - url: ADAPTA_STREAM_URL, headers, transformedBody: requestPayload, + url: ADAPTA_STREAM_URL, + headers, + transformedBody: requestPayload, }; } diff --git a/open-sse/executors/blackbox-web.ts b/open-sse/executors/blackbox-web.ts index 3a96aa677338..7f0ede8573c4 100644 --- a/open-sse/executors/blackbox-web.ts +++ b/open-sse/executors/blackbox-web.ts @@ -11,7 +11,7 @@ import { prepareToolMessages, buildToolAwareResult } from "../translator/webTool const BLACKBOX_CHAT_API = "https://app.blackbox.ai/api/chat"; const BLACKBOX_DEFAULT_COOKIE = "next-auth.session-token"; const BLACKBOX_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; const SESSION_CACHE_TTL_MS = 5 * 60_000; // 5 minutes @@ -304,9 +304,7 @@ export class BlackboxWebExecutor extends BaseExecutor { upstreamExtraHeaders, }: ExecuteInput) { const bodyObj = (body || {}) as Record; - const messages = bodyObj.messages as - | Array> - | undefined; + const messages = bodyObj.messages as Array> | undefined; if (!messages || !Array.isArray(messages) || messages.length === 0) { const errorResponse = new Response( JSON.stringify({ @@ -325,7 +323,10 @@ export class BlackboxWebExecutor extends BaseExecutor { }; } - const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, messages as Array<{ role: string; content: unknown }>); + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages( + bodyObj, + messages as Array<{ role: string; content: unknown }> + ); const chatId = crypto.randomUUID().slice(0, 7); const parsedMessages = parseOpenAIMessages(effectiveMessages, chatId); if (parsedMessages.length === 0) { @@ -649,12 +650,25 @@ export class BlackboxWebExecutor extends BaseExecutor { const created = Math.floor(Date.now() / 1000); if (hasTools) { - const { content, toolCalls, finishReason } = buildToolAwareResult(responseText, requestedTools, "bbx"); + const { content, toolCalls, finishReason } = buildToolAwareResult( + responseText, + requestedTools, + "bbx" + ); if (toolCalls) { const toolResponse = new Response( JSON.stringify({ - id, object: "chat.completion", created, model, - choices: [{ index: 0, message: { role: "assistant", content: null, tool_calls: toolCalls }, finish_reason: finishReason }], + id, + object: "chat.completion", + created, + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: null, tool_calls: toolCalls }, + finish_reason: finishReason, + }, + ], }), { status: 200, headers: { "Content-Type": "application/json" } } ); @@ -663,7 +677,11 @@ export class BlackboxWebExecutor extends BaseExecutor { const finalResponse = stream ? new Response(buildStreamingResponse(content, model, id, created), { status: 200, - headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" }, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, }) : buildNonStreamingResponse(content, model, id, created); return { response: finalResponse, url: BLACKBOX_CHAT_API, headers, transformedBody }; diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index 498ef604cb31..0c7a807f404b 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -40,7 +40,7 @@ const CONV_URL = `${CHATGPT_BASE}/backend-api/f/conversation`; const USER_LAST_USED_MODEL_CONFIG_URL = `${CHATGPT_BASE}/backend-api/settings/user_last_used_model_config`; const CHATGPT_USER_AGENT = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0"; + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0"; // Captured from a real chatgpt.com browser session (April 2026). const OAI_CLIENT_VERSION = "prod-81e0c5cdf6140e8c5db714d613337f4aeab94029"; @@ -1754,7 +1754,9 @@ function buildStreamingResponse( ); controller.enqueue(encoder.encode("data: [DONE]\n\n")); } finally { - try { controller.close(); } catch {} + try { + controller.close(); + } catch {} } }, }, diff --git a/open-sse/executors/chipotle.ts b/open-sse/executors/chipotle.ts index e1a98c1a816b..47ce8d031c74 100644 --- a/open-sse/executors/chipotle.ts +++ b/open-sse/executors/chipotle.ts @@ -36,7 +36,7 @@ class AmeliaClient { const res = await fetch(`${BASE_URL}/Amelia/api/init`, { headers: { "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", Origin: BASE_URL, Referer: `${BASE_URL}/Amelia/ui/chipotle/chat?embed=iframe`, }, @@ -79,17 +79,13 @@ class AmeliaClient { const wsUrl = `wss://amelia.chipotle.com/Amelia/api/sock/${server}/${sessionId}/websocket`; return new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error("WS connect timeout")), - 15_000, - ); + const timeout = setTimeout(() => reject(new Error("WS connect timeout")), 15_000); const ws = new WebSocket(wsUrl, { headers: { Cookie: this.session!.cookieHeader, Origin: BASE_URL, - "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", }, }); @@ -118,7 +114,7 @@ class AmeliaClient { frame: string, resolveConnect: () => void, rejectConnect: (e: Error) => void, - timeout: NodeJS.Timeout, + timeout: NodeJS.Timeout ): void { if (frame === "o") { this.sendSockJS(this.buildStompConnect()); @@ -141,21 +137,14 @@ class AmeliaClient { frame: string, resolveConnect: () => void, rejectConnect: (e: Error) => void, - timeout: NodeJS.Timeout, + timeout: NodeJS.Timeout ): void { const command = frame.split("\n")[0].replace(/\r$/, ""); if (command === "CONNECTED") { this.stompConnected = true; - this.sendSockJS( - this.buildStompSubscribe( - `/queue/session.${this.session!.userId}`, - "sub-0", - ), - ); - this.sendSockJS( - this.buildStompSubscribe("/user/queue/session", "sub-1"), - ); + this.sendSockJS(this.buildStompSubscribe(`/queue/session.${this.session!.userId}`, "sub-0")); + this.sendSockJS(this.buildStompSubscribe("/user/queue/session", "sub-1")); clearTimeout(timeout); resolveConnect(); return; @@ -176,9 +165,7 @@ class AmeliaClient { const nullIdx = frame.indexOf("\0"); let bodyStart = frame.indexOf("\n\n"); if (bodyStart === -1) bodyStart = frame.indexOf("\r\n\r\n"); - const headerLen = bodyStart !== -1 - ? (frame[bodyStart + 2] === "\r" ? 4 : 2) - : 0; + const headerLen = bodyStart !== -1 ? (frame[bodyStart + 2] === "\r" ? 4 : 2) : 0; let body = ""; if (bodyStart !== -1) { body = frame @@ -343,9 +330,9 @@ export class ChipotleExecutor extends BaseExecutor { type: "abort", code: "ABORTED", }, - }), + }) ), - { status: 499, headers: { "Content-Type": "application/json" } }, + { status: 499, headers: { "Content-Type": "application/json" } } ), url: this.buildUrl(model, stream), headers: this.buildHeaders(input.credentials), @@ -353,8 +340,8 @@ export class ChipotleExecutor extends BaseExecutor { }; } - const messages = (body as { messages?: Array<{ role: string; content: string }> }) - ?.messages ?? []; + const messages = + (body as { messages?: Array<{ role: string; content: string }> })?.messages ?? []; const lastUser = [...messages].reverse().find((m) => m.role === "user"); const prompt = lastUser?.content ?? ""; @@ -430,9 +417,9 @@ export class ChipotleExecutor extends BaseExecutor { type: "upstream_error", code: "CHIPOTLE_ERROR", }, - }), + }) ), - { status: 502, headers: { "Content-Type": "application/json" } }, + { status: 502, headers: { "Content-Type": "application/json" } } ), url: this.buildUrl(model, stream), headers: this.buildHeaders(input.credentials), @@ -442,4 +429,4 @@ export class ChipotleExecutor extends BaseExecutor { } } -export default ChipotleExecutor; \ No newline at end of file +export default ChipotleExecutor; diff --git a/open-sse/executors/claude-web.ts b/open-sse/executors/claude-web.ts index 28122331e9c2..6c384fcc7a9b 100644 --- a/open-sse/executors/claude-web.ts +++ b/open-sse/executors/claude-web.ts @@ -33,7 +33,7 @@ const CLAUDE_WEB_API_BASE = "https://claude.ai/api"; const CLAUDE_WEB_ORGS_URL = `${CLAUDE_WEB_API_BASE}/organizations`; const CLAUDE_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"; + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; // Session cookie constants const CLAUDE_SESSION_COOKIE_NAME = "sessionKey"; @@ -171,7 +171,7 @@ function getBrowserHeaders(deviceId?: string): Record { Pragma: "no-cache", Priority: "u=1, i", Referer: "https://claude.ai/new", - "Sec-Ch-Ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"', + "Sec-Ch-Ua": '"Chromium";v="149", "Not-A.Brand";v="24", "Google Chrome";v="149"', "Sec-Ch-Ua-Mobile": "?0", "Sec-Ch-Ua-Platform": '"Linux"', "Sec-Fetch-Dest": "empty", diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index 41728f8d5d26..a5c276687ab5 100644 --- a/open-sse/executors/claudeIdentity.ts +++ b/open-sse/executors/claudeIdentity.ts @@ -12,7 +12,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; // ---------- Versions ------------------------------------------------------ -export const CLAUDE_CODE_VERSION = "2.1.187"; +export const CLAUDE_CODE_VERSION = "2.1.195"; /** Bundled @anthropic-ai/sdk version for the pinned CLI release. */ export const CLAUDE_CODE_STAINLESS_VERSION = "0.94.0"; diff --git a/open-sse/executors/copilot-web.ts b/open-sse/executors/copilot-web.ts index e7f4103f8ca7..dbeeaf6b060d 100644 --- a/open-sse/executors/copilot-web.ts +++ b/open-sse/executors/copilot-web.ts @@ -26,7 +26,7 @@ const COPILOT_START_URL = `${COPILOT_BASE}/c/api/start`; const COPILOT_WS_URL = "wss://copilot.microsoft.com/c/api/chat?api-version=2"; const COPILOT_USER_AGENT = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; // Model mapping: OmniRoute model ID → Copilot mode const MODEL_MODE_MAP: Record = { diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index c9b81ddad7d2..bdc6ad1a157a 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -19,7 +19,7 @@ const FAKE_HEADERS: Record = { Origin: DEEPSEEK_WEB_BASE, Referer: `${DEEPSEEK_WEB_BASE}/`, "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", "X-App-Version": "20241129.1", "X-Client-Locale": "en-US", "X-Client-Platform": "web", diff --git a/open-sse/executors/doubao-web.ts b/open-sse/executors/doubao-web.ts index 3315b398099a..62c4a1ea801b 100644 --- a/open-sse/executors/doubao-web.ts +++ b/open-sse/executors/doubao-web.ts @@ -13,7 +13,7 @@ import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../ const BASE_URL = "https://www.doubao.com"; const CHAT_URL = `${BASE_URL}/api/chat`; const USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; export class DoubaoWebExecutor extends BaseExecutor { constructor() { @@ -95,7 +95,10 @@ export class DoubaoWebExecutor extends BaseExecutor { const stream = new ReadableStream({ async start(controller) { const reader = upstream.body?.getReader(); - if (!reader) { controller.close(); return; } + if (!reader) { + controller.close(); + return; + } let buffer = ""; try { while (true) { @@ -107,7 +110,10 @@ export class DoubaoWebExecutor extends BaseExecutor { for (const line of lines) { if (!line.startsWith("data:")) continue; const data = line.slice(5).trim(); - if (data === "[DONE]") { controller.enqueue(encoder.encode("data: [DONE]\n\n")); continue; } + if (data === "[DONE]") { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + continue; + } try { const parsed = JSON.parse(data); const text = parsed.choices?.[0]?.delta?.content || ""; @@ -135,7 +141,11 @@ export class DoubaoWebExecutor extends BaseExecutor { return { response: new Response(stream, { - headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, }), url: CHAT_URL, headers: reqHeaders, diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index b4a303490f3b..05cc4b12cd47 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -26,7 +26,7 @@ const DEFAULT_FE_VERSION = "serp_20260424_180649_ET-0bdc33b2a02ebf8f235def65d887 export const FE_VERSION_PATTERN = /serp_\d{8}_\d{6}_[A-Z]{2}-[0-9a-f]{20,40}/; const DEFAULT_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) " + - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"; + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; export const FAKE_HEADERS: Record = { Accept: "*/*", @@ -37,7 +37,7 @@ export const FAKE_HEADERS: Record = { Pragma: "no-cache", Referer: `${DUCKDUCKGO_BASE}/`, Priority: "u=1, i", - "Sec-Ch-Ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"', + "Sec-Ch-Ua": '"Chromium";v="149", "Not-A.Brand";v="24", "Google Chrome";v="149"', "Sec-Ch-Ua-Mobile": "?0", "Sec-Ch-Ua-Platform": '"Linux"', "Sec-Fetch-Dest": "empty", diff --git a/open-sse/executors/gemini-business.ts b/open-sse/executors/gemini-business.ts index ec8a4d03cdd8..9dadc3787535 100644 --- a/open-sse/executors/gemini-business.ts +++ b/open-sse/executors/gemini-business.ts @@ -34,7 +34,7 @@ import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; const GEMINI_BUSINESS_FETCH_TIMEOUT_MS = 60_000; const GEMINI_BUSINESS_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"; + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; // Default entry URL — user can override via providerSpecificData.entryUrl const DEFAULT_ENTRY_URL = "https://business.gemini.google/home"; @@ -82,8 +82,10 @@ export class GeminiBusinessExecutor extends BaseExecutor { // A user with only __Secure-1PSID (no PSIDTS) is still valid. const directCookie = readCredentialString(credentials?.apiKey) || readCredentialString(credentials?.cookie); - const psid = - readProviderSpecificString(credentials?.providerSpecificData, ["__Secure-1PSID", "cookie"]); + const psid = readProviderSpecificString(credentials?.providerSpecificData, [ + "__Secure-1PSID", + "cookie", + ]); const psidts = readProviderSpecificString(credentials?.providerSpecificData, [ "__Secure-1PSIDTS", ]); @@ -110,7 +112,12 @@ export class GeminiBusinessExecutor extends BaseExecutor { const lastUserMsg = messages.filter((m) => m.role === "user").pop(); const prompt = extractTextContent(lastUserMsg?.content); if (!prompt) { - return makeErrorResult(400, "No user message found in request body.", body, DEFAULT_ENTRY_URL); + return makeErrorResult( + 400, + "No user message found in request body.", + body, + DEFAULT_ENTRY_URL + ); } // Resolve model and its MODE_CATEGORY @@ -137,7 +144,8 @@ export class GeminiBusinessExecutor extends BaseExecutor { }; // Add SAPISID hash auth header if we can compute it (improves reliability on enterprise) - const sapisid = extractCookieValue(cookie, "SAPISID") || extractCookieValue(cookie, "__Secure-3PAPISID"); + const sapisid = + extractCookieValue(cookie, "SAPISID") || extractCookieValue(cookie, "__Secure-3PAPISID"); if (sapisid) { headers["Authorization"] = computeSapisidHash(sapisid, baseOrigin); } @@ -272,9 +280,7 @@ export function parseStreamResponse(raw: string): string { const inner = JSON.parse(payload); const responseArray = inner?.[4]?.[0]?.[1]; if (!Array.isArray(responseArray)) continue; - const chunkText = responseArray - .filter((c: unknown) => typeof c === "string") - .join(""); + const chunkText = responseArray.filter((c: unknown) => typeof c === "string").join(""); if (chunkText) textChunks.push(chunkText); } catch { // Skip unparseable lines (binary chunks, etc.) @@ -359,10 +365,7 @@ function readCredentialString(value: unknown): string { return trimmed; } -function readProviderSpecificString( - providerSpecificData: unknown, - keys: string[] -): string { +function readProviderSpecificString(providerSpecificData: unknown, keys: string[]): string { if (!providerSpecificData || typeof providerSpecificData !== "object") return ""; const data = providerSpecificData as Record; for (const key of keys) { diff --git a/open-sse/executors/gemini-web.ts b/open-sse/executors/gemini-web.ts index 4c06e2a9f077..b98cd3766b1b 100644 --- a/open-sse/executors/gemini-web.ts +++ b/open-sse/executors/gemini-web.ts @@ -33,7 +33,7 @@ export function isMissingBrowserExecutable(message: string): boolean { ); } const GEMINI_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; // ─── Types ────────────────────────────────────────────────────────────────── diff --git a/open-sse/executors/grok-cli.ts b/open-sse/executors/grok-cli.ts index 5f5f870251a5..487f3454d67e 100644 --- a/open-sse/executors/grok-cli.ts +++ b/open-sse/executors/grok-cli.ts @@ -220,9 +220,9 @@ export class GrokCliExecutor extends BaseExecutor { } headers["Accept"] = stream ? "text/event-stream" : "application/json"; - headers["x-grok-client-version"] = "0.2.64"; + headers["x-grok-client-version"] = "0.2.72"; headers["x-grok-client-identifier"] = "grok_cli_rs"; - headers["User-Agent"] = "grok-cli/0.2.64 (Windows 10.0.26200; x64)"; + headers["User-Agent"] = "grok-cli/0.2.72 (Windows 10.0.26200; x64)"; return headers; } diff --git a/open-sse/executors/grok-web.ts b/open-sse/executors/grok-web.ts index dce7309dfff9..880d95420c8e 100644 --- a/open-sse/executors/grok-web.ts +++ b/open-sse/executors/grok-web.ts @@ -32,7 +32,7 @@ import { sanitizeErrorMessage } from "../utils/error.ts"; const GROK_CHAT_API = "https://grok.com/rest/app-chat/conversations/new"; const GROK_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; // ─── Model mappings ───────────────────────────────────────────────────────── // Grok Web exposes UI modes, not stable public model IDs. Keep OmniRoute model @@ -1502,8 +1502,8 @@ function buildStreamingResponse( index: 0, delta: { content: sanitizeErrorMessage( - `[Stream error: ${err instanceof Error ? err.message : String(err)}]` - ), + `[Stream error: ${err instanceof Error ? err.message : String(err)}]` + ), }, finish_reason: "stop", logprobs: null, @@ -1514,7 +1514,9 @@ function buildStreamingResponse( ); controller.enqueue(encoder.encode("data: [DONE]\n\n")); } finally { - try { controller.close(); } catch {} + try { + controller.close(); + } catch {} } }, }, @@ -1736,7 +1738,7 @@ export class GrokWebExecutor extends BaseExecutor { Origin: "https://grok.com", Pragma: "no-cache", Referer: "https://grok.com/", - "Sec-Ch-Ua": '"Google Chrome";v="147", "Chromium";v="147", "Not(A:Brand";v="24"', + "Sec-Ch-Ua": '"Google Chrome";v="149", "Chromium";v="149", "Not(A:Brand";v="24"', "Sec-Ch-Ua-Mobile": "?0", "Sec-Ch-Ua-Platform": '"macOS"', "Sec-Fetch-Dest": "empty", diff --git a/open-sse/executors/huggingchat.ts b/open-sse/executors/huggingchat.ts index 0a587ef0209a..5d2bb91a7442 100644 --- a/open-sse/executors/huggingchat.ts +++ b/open-sse/executors/huggingchat.ts @@ -31,7 +31,7 @@ const CONVERSATION_URL = `${HUGGINGFACE_BASE}/chat/conversation`; const DEFAULT_COOKIE_NAME = "hf-chat"; const USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; const DEFAULT_MODEL = "meta-llama/Llama-3.3-70B-Instruct"; @@ -58,9 +58,10 @@ function extractTextFromContent(content: unknown): string { .trim(); } -function buildConversationPrompt( - messages: Array> -): { inputs: string; systemPrompt: string | null } { +function buildConversationPrompt(messages: Array>): { + inputs: string; + systemPrompt: string | null; +} { const systemParts: string[] = []; const conversationParts: Array<{ role: string; content: string }> = []; @@ -108,7 +109,12 @@ function estimateTokens(text: string): number { return Math.max(1, Math.ceil((text || "").length / 4)); } -function parseJsonlLine(line: string): { token?: string; done?: boolean; error?: string; text?: string } { +function parseJsonlLine(line: string): { + token?: string; + done?: boolean; + error?: string; + text?: string; +} { try { const event = JSON.parse(line); @@ -340,7 +346,9 @@ export class HuggingChatExecutor extends BaseExecutor { if (!messages || !Array.isArray(messages) || messages.length === 0) { return { response: new Response( - JSON.stringify({ error: { message: "Missing or empty messages array", type: "invalid_request" } }), + JSON.stringify({ + error: { message: "Missing or empty messages array", type: "invalid_request" }, + }), { status: 400, headers: { "Content-Type": "application/json" } } ), url: CONVERSATION_URL, @@ -375,7 +383,9 @@ export class HuggingChatExecutor extends BaseExecutor { if (!inputs.trim()) { return { response: new Response( - JSON.stringify({ error: { message: "Empty prompt after processing messages", type: "invalid_request" } }), + JSON.stringify({ + error: { message: "Empty prompt after processing messages", type: "invalid_request" }, + }), { status: 400, headers: { "Content-Type": "application/json" } } ), url: CONVERSATION_URL, @@ -434,7 +444,12 @@ export class HuggingChatExecutor extends BaseExecutor { if (!conversationId) { return { response: new Response( - JSON.stringify({ error: { message: "HuggingChat did not return a conversationId", type: "upstream_error" } }), + JSON.stringify({ + error: { + message: "HuggingChat did not return a conversationId", + type: "upstream_error", + }, + }), { status: 502, headers: { "Content-Type": "application/json" } } ), url: CONVERSATION_URL, @@ -449,7 +464,9 @@ export class HuggingChatExecutor extends BaseExecutor { log?.error?.("HUGGINGCHAT", `Conversation creation failed: ${message}`); return { response: new Response( - JSON.stringify({ error: { message: `HuggingChat connection failed: ${message}`, type: "upstream_error" } }), + JSON.stringify({ + error: { message: `HuggingChat connection failed: ${message}`, type: "upstream_error" }, + }), { status: 502, headers: { "Content-Type": "application/json" } } ), url: CONVERSATION_URL, @@ -484,7 +501,9 @@ export class HuggingChatExecutor extends BaseExecutor { log?.error?.("HUGGINGCHAT", `Message send failed: ${message}`); return { response: new Response( - JSON.stringify({ error: { message: `HuggingChat connection failed: ${message}`, type: "upstream_error" } }), + JSON.stringify({ + error: { message: `HuggingChat connection failed: ${message}`, type: "upstream_error" }, + }), { status: 502, headers: { "Content-Type": "application/json" } } ), url: messageUrl, @@ -517,7 +536,9 @@ export class HuggingChatExecutor extends BaseExecutor { if (!upstreamResponse.body) { return { response: new Response( - JSON.stringify({ error: { message: "HuggingChat returned empty response body", type: "upstream_error" } }), + JSON.stringify({ + error: { message: "HuggingChat returned empty response body", type: "upstream_error" }, + }), { status: 502, headers: { "Content-Type": "application/json" } } ), url: messageUrl, @@ -532,7 +553,13 @@ export class HuggingChatExecutor extends BaseExecutor { if (stream) { const encoder = new TextEncoder(); - const jsonlStream = streamJsonlToOpenAi(upstreamResponse.body, resolvedModel, id, created, signal); + const jsonlStream = streamJsonlToOpenAi( + upstreamResponse.body, + resolvedModel, + id, + created, + signal + ); const sseStream = new ReadableStream({ async start(controller) { @@ -573,11 +600,13 @@ export class HuggingChatExecutor extends BaseExecutor { object: "chat.completion", created, model: resolvedModel, - choices: [{ - index: 0, - message: { role: "assistant", content: fullText }, - finish_reason: "stop", - }], + choices: [ + { + index: 0, + message: { role: "assistant", content: fullText }, + finish_reason: "stop", + }, + ], usage: { prompt_tokens: estimateTokens(inputs), completion_tokens: completionTokens, diff --git a/open-sse/executors/inner-ai.ts b/open-sse/executors/inner-ai.ts index 0ab30f001a41..27a535daef23 100644 --- a/open-sse/executors/inner-ai.ts +++ b/open-sse/executors/inner-ai.ts @@ -8,7 +8,7 @@ const INNER_AI_PROFILE_URL = "https://platformapi.innerai.com/api/v1/users/profi const INNER_AI_MODELS_URL = "https://platformapi.innerai.com/api/v1/ai_models"; const INNER_AI_USER_AGENT = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; const MODELS_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour @@ -607,7 +607,10 @@ export class InnerAiExecutor extends BaseExecutor { // Build message content from OpenAI messages array const rawMessages = Array.isArray(bodyObj.messages) ? bodyObj.messages : []; - const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, rawMessages); + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages( + bodyObj, + rawMessages + ); const messages = effectiveMessages as Array>; const messageContent = buildMessageContent(messages); if (!messageContent.trim()) { @@ -704,18 +707,32 @@ export class InnerAiExecutor extends BaseExecutor { const completionId = `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; if (hasTools) { - const { content: cleaned, toolCalls, finishReason } = buildToolAwareResult(content, requestedTools, "inner"); + const { + content: cleaned, + toolCalls, + finishReason, + } = buildToolAwareResult(content, requestedTools, "inner"); if (toolCalls) { return { response: new Response( JSON.stringify({ - id: completionId, object: "chat.completion", - created: Math.floor(Date.now() / 1000), model: resolvedModel, - choices: [{ index: 0, message: { role: "assistant", content: null, tool_calls: toolCalls }, finish_reason: finishReason }], + id: completionId, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: resolvedModel, + choices: [ + { + index: 0, + message: { role: "assistant", content: null, tool_calls: toolCalls }, + finish_reason: finishReason, + }, + ], }), { status: 200, headers: { "Content-Type": "application/json" } } ), - url: INNER_AI_CHAT_URL, headers: reqHeaders, transformedBody: innerAiBody, + url: INNER_AI_CHAT_URL, + headers: reqHeaders, + transformedBody: innerAiBody, }; } content = cleaned; diff --git a/open-sse/executors/kimi-web.ts b/open-sse/executors/kimi-web.ts index 755561e9fb1c..624948028651 100644 --- a/open-sse/executors/kimi-web.ts +++ b/open-sse/executors/kimi-web.ts @@ -13,7 +13,7 @@ import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../ const BASE_URL = "https://kimi.moonshot.cn"; const CHAT_URL = `${BASE_URL}/api/chat`; const USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; export class KimiWebExecutor extends BaseExecutor { constructor() { @@ -95,7 +95,10 @@ export class KimiWebExecutor extends BaseExecutor { const stream = new ReadableStream({ async start(controller) { const reader = upstream.body?.getReader(); - if (!reader) { controller.close(); return; } + if (!reader) { + controller.close(); + return; + } let buffer = ""; try { while (true) { @@ -107,7 +110,10 @@ export class KimiWebExecutor extends BaseExecutor { for (const line of lines) { if (!line.startsWith("data:")) continue; const data = line.slice(5).trim(); - if (data === "[DONE]") { controller.enqueue(encoder.encode("data: [DONE]\n\n")); continue; } + if (data === "[DONE]") { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + continue; + } try { const parsed = JSON.parse(data); const text = parsed.choices?.[0]?.delta?.content || ""; @@ -135,7 +141,11 @@ export class KimiWebExecutor extends BaseExecutor { return { response: new Response(stream, { - headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, }), url: CHAT_URL, headers: reqHeaders, diff --git a/open-sse/executors/lmarena.ts b/open-sse/executors/lmarena.ts index ca24cf3cfff3..2f78d278db1d 100644 --- a/open-sse/executors/lmarena.ts +++ b/open-sse/executors/lmarena.ts @@ -31,7 +31,7 @@ const LMARENA_API_BASE = "https://arena.ai"; const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream`; const LMARENA_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; const LMARENA_AUTH_COOKIE = "arena-auth-prod-v1"; @@ -111,7 +111,10 @@ export function reconstructLMArenaCookie(rawCookie: string): string { const preserved = pairs.filter( (p) => p.name !== LMARENA_AUTH_COOKIE && !p.name.startsWith(chunkPrefix) ); - const rebuilt = [`${LMARENA_AUTH_COOKIE}=${joined}`, ...preserved.map((p) => `${p.name}=${p.value}`)]; + const rebuilt = [ + `${LMARENA_AUTH_COOKIE}=${joined}`, + ...preserved.map((p) => `${p.name}=${p.value}`), + ]; return rebuilt.join("; "); } @@ -146,14 +149,20 @@ export function parseArenaSSE(line: string): ArenaSSEEvent | null { } else if (line.startsWith("ag:")) { try { const content = JSON.parse(line.substring(3)); - return { type: "thinking", content: typeof content === "string" ? content : content.thinking || "" }; + return { + type: "thinking", + content: typeof content === "string" ? content : content.thinking || "", + }; } catch { return null; } } else if (line.startsWith("a3:") || line.startsWith("ae:")) { try { const content = JSON.parse(line.substring(3)); - return { type: "error", content: typeof content === "string" ? content : content.error || JSON.stringify(content) }; + return { + type: "error", + content: typeof content === "string" ? content : content.error || JSON.stringify(content), + }; } catch { return { type: "error", content: line.substring(3) }; } @@ -198,9 +207,9 @@ export class LMArenaExecutor extends BaseExecutor { protected transformRequest(body: unknown, model: string): unknown { const openaiBody = body as Record; const messages = openaiBody.messages as Array<{ role: string; content: string }>; - + return { - messages: messages.map(m => ({ + messages: messages.map((m) => ({ role: m.role, content: m.content, })), diff --git a/open-sse/executors/mimocode.ts b/open-sse/executors/mimocode.ts index 20df326af2bc..a3730488c26e 100644 --- a/open-sse/executors/mimocode.ts +++ b/open-sse/executors/mimocode.ts @@ -66,9 +66,9 @@ function injectSystemMarker(body: Record): Record [ap.fingerprint, ap.proxy] as const)) : null; diff --git a/open-sse/executors/muse-spark-web.ts b/open-sse/executors/muse-spark-web.ts index ec9a7118b0b3..f7f9ce9b1a5a 100644 --- a/open-sse/executors/muse-spark-web.ts +++ b/open-sse/executors/muse-spark-web.ts @@ -34,7 +34,7 @@ const META_AI_FRIENDLY_NAME = "useEctoSendMessageSubscription"; const META_AI_REQUEST_ANALYTICS_TAGS = "graphservice"; const META_AI_ASBD_ID = "129477"; const META_AI_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; const BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; type MuseSparkModelInfo = { @@ -1174,7 +1174,11 @@ async function buildSuccessResult( try { const json = JSON.parse(bodyText); const rawContent = json?.choices?.[0]?.message?.content || ""; - const { content, toolCalls, finishReason } = buildToolAwareResult(rawContent, requestedTools, "muse"); + const { content, toolCalls, finishReason } = buildToolAwareResult( + rawContent, + requestedTools, + "muse" + ); if (toolCalls) { json.choices[0].message = { role: "assistant", content: null, tool_calls: toolCalls }; json.choices[0].finish_reason = finishReason; @@ -1182,9 +1186,12 @@ async function buildSuccessResult( json.choices[0].message.content = content; } response = new Response(JSON.stringify(json), { - status: 200, headers: { "Content-Type": "application/json" }, + status: 200, + headers: { "Content-Type": "application/json" }, }); - } catch { /* keep original response */ } + } catch { + /* keep original response */ + } } return resultWithResponse(response, headers, transformedBody); @@ -1210,7 +1217,10 @@ export class MuseSparkWebExecutor extends BaseExecutor { return errorResult(400, "Missing or empty messages array", "invalid_request", {}, body); } - const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, rawMessages as Array<{ role: string; content: unknown }>); + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages( + bodyObj, + rawMessages as Array<{ role: string; content: unknown }> + ); const parsedHistory = parseOpenAIMessages(effectiveMessages); if (!parsedHistory.foldedPrompt) { return errorResult(400, "Empty query after processing messages", "invalid_request", {}, body); @@ -1278,6 +1288,14 @@ export class MuseSparkWebExecutor extends BaseExecutor { } rememberAssistantTurn(parsed, credentials, model, parsedHistory, conversationContext); - return buildSuccessResult(parsed, stream, model, headers, transformedBody, hasTools, requestedTools); + return buildSuccessResult( + parsed, + stream, + model, + headers, + transformedBody, + hasTools, + requestedTools + ); } } diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index 831ea400cff4..9b4397fae401 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -413,7 +413,9 @@ interface ContentChunk { // (older builds used names merely containing "markdown"). All converge on the // same answer, so we lock onto a single primary usage to avoid double-counting. function isAnswerTextUsage(usage: string): boolean { - return usage === "ask_text" || /^ask_text_\d+_markdown$/.test(usage) || usage.includes("markdown"); + return ( + usage === "ask_text" || /^ask_text_\d+_markdown$/.test(usage) || usage.includes("markdown") + ); } // Reconstructed state for one answer-text block, built up from diff patches @@ -721,7 +723,9 @@ function buildStreamingResponse( ); controller.enqueue(encoder.encode("data: [DONE]\n\n")); } finally { - try { controller.close(); } catch {} + try { + controller.close(); + } catch {} } }, }, @@ -800,9 +804,7 @@ export class PerplexityWebExecutor extends BaseExecutor { async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) { const bodyObj = (body || {}) as Record; - const rawMessages = bodyObj.messages as - | Array> - | undefined; + const rawMessages = bodyObj.messages as Array> | undefined; if (!rawMessages || !Array.isArray(rawMessages) || rawMessages.length === 0) { const errResp = new Response( JSON.stringify({ @@ -813,7 +815,10 @@ export class PerplexityWebExecutor extends BaseExecutor { return { response: errResp, url: PPLX_SSE_ENDPOINT, headers: {}, transformedBody: body }; } - const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, rawMessages as Array<{ role: string; content: unknown }>); + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages( + bodyObj, + rawMessages as Array<{ role: string; content: unknown }> + ); // Resolve thinking mode const thinking = @@ -854,7 +859,14 @@ export class PerplexityWebExecutor extends BaseExecutor { // Build Perplexity request const requestId = crypto.randomUUID(); - const pplxBody = buildPplxRequestBody(query, parsed.currentMsg, pplxMode, modelPref, followUpUuid, requestId); + const pplxBody = buildPplxRequestBody( + query, + parsed.currentMsg, + pplxMode, + modelPref, + followUpUuid, + requestId + ); const headers: Record = { "Content-Type": "application/json", @@ -989,7 +1001,11 @@ export class PerplexityWebExecutor extends BaseExecutor { try { const json = JSON.parse(bodyText); const rawContent = json?.choices?.[0]?.message?.content || ""; - const { content, toolCalls, finishReason } = buildToolAwareResult(rawContent, requestedTools, "pplx"); + const { content, toolCalls, finishReason } = buildToolAwareResult( + rawContent, + requestedTools, + "pplx" + ); if (toolCalls) { json.choices[0].message = { role: "assistant", content: null, tool_calls: toolCalls }; json.choices[0].finish_reason = finishReason; @@ -997,9 +1013,12 @@ export class PerplexityWebExecutor extends BaseExecutor { json.choices[0].message.content = content; } finalResponse = new Response(JSON.stringify(json), { - status: 200, headers: { "Content-Type": "application/json" }, + status: 200, + headers: { "Content-Type": "application/json" }, }); - } catch { /* keep original response */ } + } catch { + /* keep original response */ + } } return { diff --git a/open-sse/executors/phind.ts b/open-sse/executors/phind.ts index ac91b1798fe5..778178414a95 100644 --- a/open-sse/executors/phind.ts +++ b/open-sse/executors/phind.ts @@ -14,7 +14,7 @@ import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../ const BASE_URL = "https://www.phind.com"; const CHAT_URL = `${BASE_URL}/api/agent`; const USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; export class PhindExecutor extends BaseExecutor { constructor() { @@ -188,8 +188,7 @@ export class PhindExecutor extends BaseExecutor { } try { const parsed = JSON.parse(data); - const text = - parsed.choices?.[0]?.delta?.content || parsed.content || ""; + const text = parsed.choices?.[0]?.delta?.content || parsed.content || ""; if (text) { const chunk = { id: `chatcmpl-ph-${Date.now()}`, diff --git a/open-sse/executors/poe-web.ts b/open-sse/executors/poe-web.ts index 73d1cd5bf176..98b379127fba 100644 --- a/open-sse/executors/poe-web.ts +++ b/open-sse/executors/poe-web.ts @@ -13,7 +13,7 @@ import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../ const BASE_URL = "https://www.poe.com"; const GQL_URL = `${BASE_URL}/api/gql_POST`; const USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; // Model name mapping: OmniRoute ID -> Poe bot name const MODEL_MAP: Record = { diff --git a/open-sse/executors/qwen-web.ts b/open-sse/executors/qwen-web.ts index 82db1baebb21..b72e92c77dfe 100644 --- a/open-sse/executors/qwen-web.ts +++ b/open-sse/executors/qwen-web.ts @@ -35,7 +35,7 @@ const BASE_URL = "https://chat.qwen.ai"; const CHATS_NEW_URL = `${BASE_URL}/api/v2/chats/new`; const CHAT_COMPLETIONS_URL = `${BASE_URL}/api/v2/chat/completions`; const USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; // Anti-bot headers the v2 endpoint expects. `bx-umidtoken` is normally minted // per-session from sg-wum.alibaba.com; a captured value travels with the cookie diff --git a/open-sse/executors/t3-chat-web.ts b/open-sse/executors/t3-chat-web.ts index f01bb6a1246d..c3119fcca7d1 100644 --- a/open-sse/executors/t3-chat-web.ts +++ b/open-sse/executors/t3-chat-web.ts @@ -25,7 +25,7 @@ export const T3_CHAT_BASE = "https://t3.chat"; const SERVER_FN_PREFIX = `${T3_CHAT_BASE}/_serverFn/`; const USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; /** TanStack Start accepts these content types, in priority order */ const TSS_ACCEPT = "application/x-tss-framed, application/x-ndjson, application/json"; @@ -151,90 +151,90 @@ function transformTSSStream(upstreamStream: ReadableStream, model: string): Read return new ReadableStream( { - async start(controller) { - const reader = upstreamStream.getReader(); - let buffer = ""; + async start(controller) { + const reader = upstreamStream.getReader(); + let buffer = ""; - const emit = (obj: object) => { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`)); - }; - - const chunk = (delta: object, finish?: string | null) => { - emit({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta, finish_reason: finish ?? null }], - }); - }; + const emit = (obj: object) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`)); + }; - const close = () => { - if (!emittedRole) { - emittedRole = true; - chunk({ role: "assistant", content: "" }); - } - chunk({}, "stop"); - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - controller.close(); - }; + const chunk = (delta: object, finish?: string | null) => { + emit({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta, finish_reason: finish ?? null }], + }); + }; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; + const close = () => { + if (!emittedRole) { + emittedRole = true; + chunk({ role: "assistant", content: "" }); + } + chunk({}, "stop"); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }; - buffer += decoder.decode(value, { stream: true }); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; - // Handle both NDJSON (newline-delimited) and SSE (data: prefix) formats - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; + buffer += decoder.decode(value, { stream: true }); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; + // Handle both NDJSON (newline-delimited) and SSE (data: prefix) formats + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; - // SSE format: "data: {...}" - const payload = trimmed.startsWith("data: ") ? trimmed.slice(6).trim() : trimmed; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; - if (payload === "[DONE]") { - close(); - return; - } + // SSE format: "data: {...}" + const payload = trimmed.startsWith("data: ") ? trimmed.slice(6).trim() : trimmed; - let data: Record; - try { - data = JSON.parse(payload); - } catch { - continue; - } + if (payload === "[DONE]") { + close(); + return; + } - // TSS format: extract text content from typed envelope - // t:10 = object with keys in p.k and values in p.v - // t:0 = number (value in s), t:2 = string (value in s), t:9 = array - const textContent = extractTextFromTSS(data); + let data: Record; + try { + data = JSON.parse(payload); + } catch { + continue; + } - if (typeof textContent === "string" && textContent.length > 0) { - if (!emittedRole) { - emittedRole = true; - chunk({ role: "assistant", content: "" }); + // TSS format: extract text content from typed envelope + // t:10 = object with keys in p.k and values in p.v + // t:0 = number (value in s), t:2 = string (value in s), t:9 = array + const textContent = extractTextFromTSS(data); + + if (typeof textContent === "string" && textContent.length > 0) { + if (!emittedRole) { + emittedRole = true; + chunk({ role: "assistant", content: "" }); + } + chunk({ content: textContent }); } - chunk({ content: textContent }); - } - // Detect end-of-stream markers - if (isTSSDone(data)) { - close(); - return; + // Detect end-of-stream markers + if (isTSSDone(data)) { + close(); + return; + } } } + } catch { + // Stream error — fall through to close } - } catch { - // Stream error — fall through to close - } - close(); - }, + close(); + }, }, { highWaterMark: 16384 } ); @@ -349,7 +349,10 @@ export class T3ChatWebExecutor extends BaseExecutor { role: string; content: string | unknown; }>; - const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, rawMessages); + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages( + bodyObj, + rawMessages + ); // 1. Parse + validate credentials. The credential pipeline stores the single // pasted string as `apiKey` (fallback `accessToken`); parse out the Cookie // header + convex-session-id (#3007) instead of expecting pre-structured fields. @@ -492,31 +495,50 @@ export class T3ChatWebExecutor extends BaseExecutor { const rawContent = await collectStreamContent(resp.body); if (hasTools) { - const { content, toolCalls, finishReason } = buildToolAwareResult(rawContent, requestedTools, "t3"); + const { content, toolCalls, finishReason } = buildToolAwareResult( + rawContent, + requestedTools, + "t3" + ); if (toolCalls) { return { response: new Response( JSON.stringify({ - id: `chatcmpl-t3-${Date.now()}`, object: "chat.completion", - created: Math.floor(Date.now() / 1000), model: model || "unknown", - choices: [{ index: 0, message: { role: "assistant", content: null, tool_calls: toolCalls }, finish_reason: finishReason }], + id: `chatcmpl-t3-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: model || "unknown", + choices: [ + { + index: 0, + message: { role: "assistant", content: null, tool_calls: toolCalls }, + finish_reason: finishReason, + }, + ], }), { status: 200, headers: { "Content-Type": "application/json" } } ), - url: completionUrl, headers, transformedBody: requestPayload, + url: completionUrl, + headers, + transformedBody: requestPayload, }; } const openaiResponse = { - id: `chatcmpl-t3-${Date.now()}`, object: "chat.completion", - created: Math.floor(Date.now() / 1000), model: model || "unknown", + id: `chatcmpl-t3-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: model || "unknown", choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, }; return { response: new Response(JSON.stringify(openaiResponse), { - status: 200, headers: { "Content-Type": "application/json" }, + status: 200, + headers: { "Content-Type": "application/json" }, }), - url: completionUrl, headers, transformedBody: requestPayload, + url: completionUrl, + headers, + transformedBody: requestPayload, }; } diff --git a/open-sse/executors/theoldllm.ts b/open-sse/executors/theoldllm.ts index 46fee0ed1ffd..46804cdf7767 100644 --- a/open-sse/executors/theoldllm.ts +++ b/open-sse/executors/theoldllm.ts @@ -5,7 +5,7 @@ const API_BASE = "https://theoldllm.vercel.app"; const API_PATH = "/api/chatgpt"; const API_URL = `${API_BASE}${API_PATH}`; const CHROME_UA = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; // ── Model name mapping ──────────────────────────────────────────────────── @@ -15,25 +15,25 @@ const GPT_MODELS: Record = { "gpt-5.2": "GPT_5_2", "gpt-5.1": "GPT_5_1", "gpt-5": "GPT_5", - "gpt5_4": "GPT_5_4", - "gpt5_3": "GPT_5_3", - "gpt5_2": "GPT_5_2", - "gpt5_1": "GPT_5_1", - "gpt_4o": "GPT_4O", + gpt5_4: "GPT_5_4", + gpt5_3: "GPT_5_3", + gpt5_2: "GPT_5_2", + gpt5_1: "GPT_5_1", + gpt_4o: "GPT_4O", "gpt-4o": "GPT_4O", - "gpt_5_3": "GPT_5_3", - "gpt_5_2": "GPT_5_2", - "gpt_5_1": "GPT_5_1", - "gpt_5": "GPT_5", + gpt_5_3: "GPT_5_3", + gpt_5_2: "GPT_5_2", + gpt_5_1: "GPT_5_1", + gpt_5: "GPT_5", }; const CLAUDE_NAMES: Record = { "claude-4.6-opus": "CLAUDE_4_6_OPUS", "claude-4.6-sonnet": "CLAUDE_4_6_SONNET", "claude-4.5-haiku": "CLAUDE_4_5_HAIKU", - "claude_opus_4": "CLAUDE_4_6_OPUS", - "claude_sonnet_4": "CLAUDE_4_6_SONNET", - "claude_haiku_3_5": "CLAUDE_4_5_HAIKU", + claude_opus_4: "CLAUDE_4_6_OPUS", + claude_sonnet_4: "CLAUDE_4_6_SONNET", + claude_haiku_3_5: "CLAUDE_4_5_HAIKU", "claude opus 4": "CLAUDE_4_6_OPUS", "claude sonnet 4": "CLAUDE_4_6_SONNET", "claude haiku 3.5": "CLAUDE_4_5_HAIKU", @@ -92,13 +92,11 @@ export const tokenCache: { value: string; expiresAt: number } = { value: "", exp async function directFetch( reqBody: Record, - signal?: AbortSignal | null, + signal?: AbortSignal | null ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 120_000); - const onSignal = signal - ? () => controller.abort(signal.reason) - : undefined; + const onSignal = signal ? () => controller.abort(signal.reason) : undefined; signal?.addEventListener("abort", onSignal!, { once: true }); try { @@ -164,7 +162,10 @@ function buildErrorResponse(status: number, body: string): string { if (line.startsWith("data: ") && line !== "data: [DONE]") { try { const p = JSON.parse(line.slice(6)); - if (p.error) { detail = JSON.stringify(p.error); break; } + if (p.error) { + detail = JSON.stringify(p.error); + break; + } } catch {} } } @@ -202,7 +203,7 @@ export class TheOldLlmExecutor extends BaseExecutor { async testConnection( _credentials: ProviderCredentials, _signal?: AbortSignal | null, - log?: ExecuteInput["log"], + log?: ExecuteInput["log"] ): Promise { try { const resp = await fetch(API_URL, { @@ -238,9 +239,14 @@ export class TheOldLlmExecutor extends BaseExecutor { if (signal?.aborted) { return { - response: new Response(encoder.encode(JSON.stringify({ - error: { message: "Request aborted", type: "abort", code: "ABORTED" }, - })), { status: 499, headers: { "Content-Type": "application/json" } }), + response: new Response( + encoder.encode( + JSON.stringify({ + error: { message: "Request aborted", type: "abort", code: "ABORTED" }, + }) + ), + { status: 499, headers: { "Content-Type": "application/json" } } + ), url: API_URL, headers: this.buildHeaders(input.credentials), transformedBody: body, @@ -264,9 +270,7 @@ export class TheOldLlmExecutor extends BaseExecutor { } if (upstream.status === 200 && finalBody) { - const payload = stream - ? finalBody - : buildChatCompletion(parseSseContent(finalBody), model); + const payload = stream ? finalBody : buildChatCompletion(parseSseContent(finalBody), model); return { response: new Response(encoder.encode(payload), { status: 200, @@ -298,9 +302,9 @@ export class TheOldLlmExecutor extends BaseExecutor { encoder.encode( JSON.stringify({ error: { message: msg, type: "upstream_error", code: "EXECUTOR_ERROR" }, - }), + }) ), - { status: 502, headers: { "Content-Type": "application/json" } }, + { status: 502, headers: { "Content-Type": "application/json" } } ), url: API_URL, headers: this.buildHeaders(input.credentials), diff --git a/open-sse/executors/trae.ts b/open-sse/executors/trae.ts index 47b306fc9342..e5a75ebb51cc 100644 --- a/open-sse/executors/trae.ts +++ b/open-sse/executors/trae.ts @@ -69,7 +69,7 @@ export class TraeExecutor extends BaseExecutor { Referer: "https://solo.trae.ai/", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + - "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", + "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", }; } diff --git a/open-sse/executors/v0-vercel-web.ts b/open-sse/executors/v0-vercel-web.ts index 906450367346..eb6d276b009e 100644 --- a/open-sse/executors/v0-vercel-web.ts +++ b/open-sse/executors/v0-vercel-web.ts @@ -13,7 +13,7 @@ import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../ const BASE_URL = "https://v0.dev"; const CHAT_URL = `${BASE_URL}/api/chat`; const USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; export class V0VercelWebExecutor extends BaseExecutor { constructor() { diff --git a/open-sse/executors/venice-web.ts b/open-sse/executors/venice-web.ts index 4007815f5e19..1dc8fc80baf7 100644 --- a/open-sse/executors/venice-web.ts +++ b/open-sse/executors/venice-web.ts @@ -13,7 +13,7 @@ import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../ const BASE_URL = "https://venice.ai"; const CHAT_URL = `${BASE_URL}/api/chat`; const USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; export class VeniceWebExecutor extends BaseExecutor { constructor() { diff --git a/open-sse/executors/veoaifree-web.ts b/open-sse/executors/veoaifree-web.ts index 56e81ae41c48..5d62bf0771be 100644 --- a/open-sse/executors/veoaifree-web.ts +++ b/open-sse/executors/veoaifree-web.ts @@ -13,7 +13,7 @@ const BASE_URL = "https://veoaifree.com"; const AJAX_URL = `${BASE_URL}/wp-admin/admin-ajax.php`; const TTS_URL = `${BASE_URL}/video/googletts.php`; const USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; const POLL_INTERVAL_MS = 20_000; const MAX_POLLS = 30; // 10 minutes max const FETCH_TIMEOUT_MS = 30_000; @@ -363,7 +363,12 @@ export class VeoAIFreeWebExecutor extends BaseExecutor { nonce = await fetchNonce(input.signal); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to get nonce"; - return { response: errResp(sanitizeErrorMessage(msg)), url: BASE_URL, headers: {}, transformedBody: null }; + return { + response: errResp(sanitizeErrorMessage(msg)), + url: BASE_URL, + headers: {}, + transformedBody: null, + }; } // Extract aspect ratio from system prompt or default diff --git a/open-sse/services/antigravityHeaders.ts b/open-sse/services/antigravityHeaders.ts index b0829ad38c92..beb574958022 100644 --- a/open-sse/services/antigravityHeaders.ts +++ b/open-sse/services/antigravityHeaders.ts @@ -17,7 +17,7 @@ type AntigravityHeaderProfile = "loadCodeAssist" | "fetchAvailableModels" | "mod const ANTIGRAVITY_VERSION = ANTIGRAVITY_FALLBACK_VERSION; // IDE desktop fingerprint synced with Antigravity-Manager v4.2.0 constants.rs. -export const ANTIGRAVITY_CHROME_VERSION = "132.0.6834.160"; +export const ANTIGRAVITY_CHROME_VERSION = "142.0.7444.175"; export const ANTIGRAVITY_ELECTRON_VERSION = "39.2.3"; export const ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT = `vscode/1.X.X (Antigravity/${ANTIGRAVITY_FALLBACK_VERSION})`; export const ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT = ""; @@ -50,7 +50,7 @@ function getAntigravityPlatformInfo(platform: NodeJS.Platform = process.platform /** * Antigravity desktop User-Agent: - * "Antigravity/VERSION (PLATFORM) Chrome/132... Electron/39..." + * "Antigravity/VERSION (PLATFORM) Chrome/142... Electron/39..." */ export function antigravityUserAgent( version = getCachedAntigravityVersion(), diff --git a/open-sse/services/autoRefreshDaemon.ts b/open-sse/services/autoRefreshDaemon.ts index a08471f65512..120b08154501 100644 --- a/open-sse/services/autoRefreshDaemon.ts +++ b/open-sse/services/autoRefreshDaemon.ts @@ -154,7 +154,7 @@ class AutoRefreshDaemon { signal: controller.signal, headers: { "User-Agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", }, }); diff --git a/open-sse/services/browserBackedChat.ts b/open-sse/services/browserBackedChat.ts index 96d4f949f3d7..71e63b46d3c3 100644 --- a/open-sse/services/browserBackedChat.ts +++ b/open-sse/services/browserBackedChat.ts @@ -445,7 +445,7 @@ export async function httpBackedChat( // Build browser-emulated headers const headers: Record = { "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", Accept: "text/event-stream, application/json, text/plain, */*", "Accept-Language": "en-US,en;q=0.9", "Content-Type": "application/json", @@ -460,7 +460,7 @@ export async function httpBackedChat( "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", - "Sec-Ch-Ua": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"', + "Sec-Ch-Ua": '"Chromium";v="149", "Google Chrome";v="149", "Not-A.Brand";v="99"', "Sec-Ch-Ua-Mobile": "?0", "Sec-Ch-Ua-Platform": '"macOS"', Priority: "u=1, i", diff --git a/open-sse/services/browserPool.ts b/open-sse/services/browserPool.ts index 5343205cea12..27dfba51d864 100644 --- a/open-sse/services/browserPool.ts +++ b/open-sse/services/browserPool.ts @@ -95,7 +95,7 @@ const POOL_IDLE_TIMEOUT_MS = 5 * 60 * 1000; const CONTEXT_TTL_MS = 10 * 60 * 1000; // 10 min — evict stale contexts const EVICT_INTERVAL_MS = 60 * 1000; // check every 60s const DEFAULT_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"; + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; const state: PoolState = { browser: null, @@ -185,7 +185,7 @@ interface ResolvePlaywrightProxyDeps { // Exported for tests (deps injection avoids mock.module()). export async function resolvePlaywrightProxy( providerKey: string, - deps?: ResolvePlaywrightProxyDeps, + deps?: ResolvePlaywrightProxyDeps ): Promise { try { const resolver = diff --git a/open-sse/services/ccBridgeTransforms.ts b/open-sse/services/ccBridgeTransforms.ts index d45c980dd3e7..ae444f97a93f 100644 --- a/open-sse/services/ccBridgeTransforms.ts +++ b/open-sse/services/ccBridgeTransforms.ts @@ -96,7 +96,7 @@ export interface InjectBillingHeaderOp { * - static-zero: emit "00000" (relay endpoints don't validate) */ cchAlgo: "sha256-first-user" | "xxhash64-body" | "static-zero"; - /** Override the embedded `cc_version=` value. Defaults to `2.1.137`. */ + /** Override the embedded `cc_version=` value. Defaults to `2.1.195`. */ version?: string; } @@ -114,7 +114,7 @@ export const CCH_SALT = "59cf53e54c78"; /** Character positions sampled from the first user message text. */ export const CCH_POSITIONS = [4, 7, 20] as const; /** Default `cc_version=` value embedded in the billing header. */ -export const DEFAULT_CLAUDE_CODE_VERSION = "2.1.187"; +export const DEFAULT_CLAUDE_CODE_VERSION = "2.1.195"; /** Identity sentinel prepended for Claude Agent SDK callers. */ export const CLAUDE_AGENT_SDK_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK."; diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index a23642409aa2..c9fd70f58fd5 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -41,8 +41,8 @@ export { CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA, resolveClaudeCodeCompatibleAnthropicBeta, } from "./claudeCodeCompatibleBeta.ts"; -export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.187"; -export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.187 (external, sdk-cli)"; +export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.195"; +export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.195 (external, sdk-cli)"; export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.94.0"; export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0"; export const CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07"; diff --git a/open-sse/services/claudeTlsClient.ts b/open-sse/services/claudeTlsClient.ts index 8674b9f98b93..9e09e832ac0a 100644 --- a/open-sse/services/claudeTlsClient.ts +++ b/open-sse/services/claudeTlsClient.ts @@ -20,7 +20,7 @@ import { randomUUID } from "node:crypto"; let clientPromise: Promise | null = null; let exitHookInstalled = false; -const CLAUDE_PROFILE = "chrome_146"; // matches the Vivaldi/Chrome 146 UA we send +const CLAUDE_PROFILE = "chrome_149"; // matches the Vivaldi/Chrome 149 UA we send const DEFAULT_TIMEOUT_MS = Number.parseInt(process.env.OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS || "", 10) || 60_000; // Grace period added to the binding's wire-level timeout before our JS-level diff --git a/open-sse/services/claudeTurnstileSolver.ts b/open-sse/services/claudeTurnstileSolver.ts index 01e26adf7c9c..c206edf56300 100644 --- a/open-sse/services/claudeTurnstileSolver.ts +++ b/open-sse/services/claudeTurnstileSolver.ts @@ -84,7 +84,7 @@ export async function solveTurnstile(options?: { browser = await chromium.launch({ headless }); const context = await browser.newContext({ userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", viewport: { width: 1280, height: 720 }, ignoreHTTPSErrors: process.env.OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS === "true", }); diff --git a/open-sse/services/freeWebSearch.ts b/open-sse/services/freeWebSearch.ts index ffd4318eae8e..b8cdbfbbb967 100644 --- a/open-sse/services/freeWebSearch.ts +++ b/open-sse/services/freeWebSearch.ts @@ -20,7 +20,7 @@ export interface FreeSearchResult { const DUCKDUCKGO_LITE_URL = "https://lite.duckduckgo.com/lite/"; // A browser-like UA — the lite endpoint rejects obvious bot agents. const DDG_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"; + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0 Safari/537.36"; // Real lite shape: `Title` (href usually // before class; quotes may be single or double) and `…`. diff --git a/open-sse/services/geminiCliHeaders.ts b/open-sse/services/geminiCliHeaders.ts index b958a0e9dd4e..01a05f3c8376 100644 --- a/open-sse/services/geminiCliHeaders.ts +++ b/open-sse/services/geminiCliHeaders.ts @@ -4,7 +4,7 @@ import { normalizeCloudCodePlatform, } from "./cloudCodeHeaders.ts"; -export const GEMINI_CLI_VERSION = "0.42.0"; +export const GEMINI_CLI_VERSION = "0.49.0"; export const GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION = "10.3.0"; const GEMINI_CLI_LOAD_CODE_ASSIST_METADATA = Object.freeze({ diff --git a/open-sse/services/grokTlsClient.ts b/open-sse/services/grokTlsClient.ts index 198ed9e47955..cdc888f896cb 100644 --- a/open-sse/services/grokTlsClient.ts +++ b/open-sse/services/grokTlsClient.ts @@ -25,7 +25,7 @@ import { randomUUID } from "node:crypto"; let clientPromise: Promise | null = null; let exitHookInstalled = false; -const GROK_PROFILE = "chrome_146"; // closest Chrome profile to the UA we send +const GROK_PROFILE = "chrome_149"; // closest Chrome profile to the UA we send const DEFAULT_TIMEOUT_MS = Number.parseInt(process.env.OMNIROUTE_GROK_TLS_TIMEOUT_MS || "", 10) || 60_000; // Grace period added to the binding's wire-level timeout before our JS-level diff --git a/open-sse/services/opencodeOllamaUsage.ts b/open-sse/services/opencodeOllamaUsage.ts index 7a8befa01c83..25cd3c3cc98f 100644 --- a/open-sse/services/opencodeOllamaUsage.ts +++ b/open-sse/services/opencodeOllamaUsage.ts @@ -283,7 +283,7 @@ async function fetchOpenCodeGoDashboardUsage( headers: { Accept: "text/html", Cookie: `auth=${normalizeOpenCodeGoAuthCookie(config.authCookie)}`, - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/152.0", }, signal: AbortSignal.timeout(10_000), }); @@ -508,7 +508,7 @@ async function fetchOllamaCloudUsageFromSettings( headers: { Accept: "text/html", Cookie: `${OLLAMA_CLOUD_SESSION_COOKIE}=${normalizeOllamaCloudCookie(config.cookie)}`, - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/152.0", }, signal: AbortSignal.timeout(10_000), }); diff --git a/open-sse/services/sessionPool/fingerprintRotator.ts b/open-sse/services/sessionPool/fingerprintRotator.ts index bc3dc248c52a..479a3b808a87 100644 --- a/open-sse/services/sessionPool/fingerprintRotator.ts +++ b/open-sse/services/sessionPool/fingerprintRotator.ts @@ -14,40 +14,39 @@ const PROFILES: Fingerprint[] = [ { id: "chrome-mac", userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", acceptLanguage: "en-US,en;q=0.9", - secChUa: '"Not-A.Brand";v="99", "Chromium";v="135", "Google Chrome";v="135"', + secChUa: '"Not-A.Brand";v="99", "Chromium";v="149", "Google Chrome";v="149"', secChUaPlatform: '"macOS"', secChUaMobile: "?0", }, { id: "chrome-linux", userAgent: - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", acceptLanguage: "en-US,en;q=0.9", - secChUa: '"Not-A.Brand";v="99", "Chromium";v="135", "Google Chrome";v="135"', + secChUa: '"Not-A.Brand";v="99", "Chromium";v="149", "Google Chrome";v="149"', secChUaPlatform: '"Linux"', secChUaMobile: "?0", }, { id: "chrome-win", userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", acceptLanguage: "en-US,en;q=0.9", - secChUa: '"Not-A.Brand";v="99", "Chromium";v="135", "Google Chrome";v="135"', + secChUa: '"Not-A.Brand";v="99", "Chromium";v="149", "Google Chrome";v="149"', secChUaPlatform: '"Windows"', secChUaMobile: "?0", }, { id: "firefox-mac", userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0", acceptLanguage: "en-US,en;q=0.9", }, { id: "firefox-linux", - userAgent: - "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0", + userAgent: "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", acceptLanguage: "en-US,en;q=0.9", }, { @@ -59,18 +58,18 @@ const PROFILES: Fingerprint[] = [ { id: "edge-mac", userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0", acceptLanguage: "en-US,en;q=0.9", - secChUa: '"Not-A.Brand";v="99", "Chromium";v="135", "Microsoft Edge";v="135"', + secChUa: '"Not-A.Brand";v="99", "Chromium";v="149", "Microsoft Edge";v="149"', secChUaPlatform: '"macOS"', secChUaMobile: "?0", }, { id: "edge-win", userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0", acceptLanguage: "en-US,en;q=0.9", - secChUa: '"Not-A.Brand";v="99", "Chromium";v="135", "Microsoft Edge";v="135"', + secChUa: '"Not-A.Brand";v="99", "Chromium";v="149", "Microsoft Edge";v="149"', secChUaPlatform: '"Windows"', secChUaMobile: "?0", }, @@ -104,10 +103,7 @@ export class FingerprintRotator { } /** Build a Headers object from a fingerprint (with optional extras) */ - buildHeaders( - fingerprint: Fingerprint, - extra?: Record, - ): Record { + buildHeaders(fingerprint: Fingerprint, extra?: Record): Record { const headers: Record = { "Content-Type": "application/json", Accept: "application/json, text/plain, */*", diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 76ac708a5061..e9814e29c19e 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -97,7 +97,7 @@ const CURSOR_USAGE_CONFIG = { origin: "https://cursor.com", referer: "https://cursor.com/dashboard/spending", userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", }; type JsonRecord = Record; diff --git a/open-sse/utils/cursorVersionDetector.ts b/open-sse/utils/cursorVersionDetector.ts index 931f77622bc1..29a6cdae6d87 100644 --- a/open-sse/utils/cursorVersionDetector.ts +++ b/open-sse/utils/cursorVersionDetector.ts @@ -18,7 +18,7 @@ const DB_KEY = "cursorupdate.lastUpdatedAndShown.version"; * `CURSOR_REGISTRY_VERSION` in providerHeaderProfiles.ts. Exported so tests * assert against the single source of truth instead of a drifting literal. */ -export const FALLBACK_VERSION = "3.3"; +export const FALLBACK_VERSION = "3.9"; let cachedVersion: string | null = null; let cachedAt = 0; diff --git a/scripts/ad-hoc/diag-trae-auth.mjs b/scripts/ad-hoc/diag-trae-auth.mjs index fcacb0b3fd6f..fc9c5d37faea 100644 --- a/scripts/ad-hoc/diag-trae-auth.mjs +++ b/scripts/ad-hoc/diag-trae-auth.mjs @@ -26,7 +26,7 @@ const commonHeaders = { Referer: "https://solo.trae.ai/", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + - "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", + "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", }; // Each variant = a set of auth-bearing headers to try. diff --git a/scripts/dev/responses-ws-proxy.mjs b/scripts/dev/responses-ws-proxy.mjs index abbb435e8f65..5fa562d752f6 100644 --- a/scripts/dev/responses-ws-proxy.mjs +++ b/scripts/dev/responses-ws-proxy.mjs @@ -616,7 +616,7 @@ class ResponsesWsSession { }; const upstream = await this.wsFactory(prepared.json.upstreamUrl, { - browser: prepared.json.browser || "chrome_142", + browser: prepared.json.browser || "chrome_149", os: prepared.json.os || "windows", headers: prepared.json.headers || {}, }); diff --git a/src/app/api/internal/codex-responses-ws/route.ts b/src/app/api/internal/codex-responses-ws/route.ts index ddb4760dcfe6..c9676a59c3c7 100644 --- a/src/app/api/internal/codex-responses-ws/route.ts +++ b/src/app/api/internal/codex-responses-ws/route.ts @@ -411,7 +411,7 @@ async function prepare(body: JsonRecord) { return NextResponse.json({ ok: true, upstreamUrl: CODEX_RESPONSES_WS_URL, - browser: "chrome_142", + browser: "chrome_149", os: "windows", connectionId: refreshedCredentials.connectionId, provider, diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 5e680024f772..3c51274211ec 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -1818,7 +1818,7 @@ export async function GET( const innerAiHeaders: Record = { "USER-TOKEN": innerAiToken, "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", Origin: "https://app.innerai.com", Referer: "https://app.innerai.com/", }; diff --git a/src/lib/providers/validation/headers.ts b/src/lib/providers/validation/headers.ts index 61b68b67afe4..24116c36bd73 100644 --- a/src/lib/providers/validation/headers.ts +++ b/src/lib/providers/validation/headers.ts @@ -3,10 +3,9 @@ // which delegates to safeOutboundFetch with bypassProxyPatch. Behavior is byte-identical. import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; - // Standardized desktop Chrome UA for web-cookie/no-auth session probes (minimizes anti-bot detection). export const STANDARD_USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; export function getCustomUserAgent(providerSpecificData: any = {}) { if (typeof providerSpecificData?.customUserAgent !== "string") return null; @@ -14,7 +13,10 @@ export function getCustomUserAgent(providerSpecificData: any = {}) { return customUserAgent || null; } -export function applyCustomUserAgent(headers: Record, providerSpecificData: any = {}) { +export function applyCustomUserAgent( + headers: Record, + providerSpecificData: any = {} +) { const customUserAgent = getCustomUserAgent(providerSpecificData); if (!customUserAgent) return headers; headers["User-Agent"] = customUserAgent; diff --git a/src/lib/providers/validation/metaAi.ts b/src/lib/providers/validation/metaAi.ts index aedc190f90a8..524681f684cd 100644 --- a/src/lib/providers/validation/metaAi.ts +++ b/src/lib/providers/validation/metaAi.ts @@ -7,8 +7,9 @@ export const META_AI_FRIENDLY_NAME = "useEctoSendMessageSubscription"; export const META_AI_REQUEST_ANALYTICS_TAGS = "graphservice"; export const META_AI_ASBD_ID = "129477"; export const META_AI_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; -export const META_AI_BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; +export const META_AI_BASE62_ALPHABET = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; export function encodeMetaAiBase62(value: bigint, padLength: number): string { let remaining = value; diff --git a/src/lib/providers/validation/webProvidersA.ts b/src/lib/providers/validation/webProvidersA.ts index c8439b8c966d..982475eeb5a0 100644 --- a/src/lib/providers/validation/webProvidersA.ts +++ b/src/lib/providers/validation/webProvidersA.ts @@ -36,7 +36,7 @@ export async function validateDeepSeekWebProvider({ apiKey }: any) { Origin: "https://chat.deepseek.com", Referer: "https://chat.deepseek.com/", "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", "X-App-Version": "20241129.1", "X-Client-Platform": "web", }, @@ -95,7 +95,7 @@ export async function validateQwenWebProvider({ apiKey }: any) { const headers: Record = { Accept: "*/*", "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", Origin: "https://chat.qwen.ai", Referer: "https://chat.qwen.ai/", source: "web", @@ -225,14 +225,14 @@ export async function validateGrokWebProvider({ apiKey, providerSpecificData = { Origin: "https://grok.com", Pragma: "no-cache", Referer: "https://grok.com/", - "Sec-Ch-Ua": '"Google Chrome";v="147", "Chromium";v="147", "Not(A:Brand";v="24"', + "Sec-Ch-Ua": '"Google Chrome";v="149", "Chromium";v="149", "Not(A:Brand";v="24"', "Sec-Ch-Ua-Mobile": "?0", "Sec-Ch-Ua-Platform": '"macOS"', "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", "x-statsig-id": btoa(statsigMsg), "x-xai-request-id": crypto.randomUUID(), traceparent: `00-${traceId}-${spanId}-00`, @@ -385,7 +385,7 @@ export async function validateChatGptWebProvider({ apiKey, providerSpecificData "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0", }, providerSpecificData ), @@ -480,7 +480,7 @@ export async function validatePerplexityWebProvider({ apiKey, providerSpecificDa Referer: "https://www.perplexity.ai/", // Firefox 148 — must match the firefox_148 TLS profile of perplexityTlsClient (issue #2459). "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0", "X-App-ApiClient": "default", "X-App-ApiVersion": "client-1.11.0", ...(bearerToken @@ -577,7 +577,7 @@ export async function validateBlackboxWebProvider({ apiKey, providerSpecificData Origin: "https://app.blackbox.ai", Referer: "https://app.blackbox.ai/", "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/147.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", }, providerSpecificData ); @@ -607,7 +607,7 @@ export async function validateBlackboxWebProvider({ apiKey, providerSpecificData Origin: "https://app.blackbox.ai", Referer: "https://app.blackbox.ai/", "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/147.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", }, providerSpecificData ); @@ -678,4 +678,3 @@ export async function validateBlackboxWebProvider({ apiKey, providerSpecificData return toValidationErrorResult(error); } } - diff --git a/src/lib/providers/validation/webProvidersB.ts b/src/lib/providers/validation/webProvidersB.ts index b96e0976583a..62a721c0d1a9 100644 --- a/src/lib/providers/validation/webProvidersB.ts +++ b/src/lib/providers/validation/webProvidersB.ts @@ -88,7 +88,7 @@ export async function validateAdaptaWebProvider({ apiKey, providerSpecificData = { Cookie: `__client=${clientJwt}`, "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", Origin: "https://agent.adapta.one", }, providerSpecificData @@ -149,7 +149,7 @@ export async function validateClaudeWebProvider({ apiKey, providerSpecificData = "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", "anthropic-client-platform": "web_claude_ai", }, providerSpecificData diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 983aa1c6b7c4..b7a8a6b23fcd 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -31,7 +31,7 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.187 (external, cli)", + "User-Agent": "claude-cli/2.1.195 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "x64", "X-Stainless-Helper-Method": "stream", @@ -49,7 +49,7 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.187 (external, cli)", + "User-Agent": "claude-cli/2.1.195 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "x64", "X-Stainless-Helper-Method": "stream", @@ -68,7 +68,7 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.187 (external, cli)", + "User-Agent": "claude-cli/2.1.195 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "x64", "X-Stainless-Helper-Method": "stream", @@ -94,18 +94,18 @@ "Accept": "text/event-stream", "Authorization": "Bearer ", "Content-Type": "application/json", - "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/132.0.6834.160 Electron/39.2.3" + "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/142.0.7444.175 Electron/39.2.3" }, "nonStream": { "Authorization": "Bearer ", "Content-Type": "application/json", - "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/132.0.6834.160 Electron/39.2.3" + "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/142.0.7444.175 Electron/39.2.3" }, "oauth": { "Accept": "text/event-stream", "Authorization": "Bearer ", "Content-Type": "application/json", - "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/132.0.6834.160 Electron/39.2.3" + "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/142.0.7444.175 Electron/39.2.3" } }, "url": { @@ -241,18 +241,18 @@ "Accept": "text/event-stream", "Authorization": "Bearer ", "Content-Type": "application/json", - "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/132.0.6834.160 Electron/39.2.3" + "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/142.0.7444.175 Electron/39.2.3" }, "nonStream": { "Authorization": "Bearer ", "Content-Type": "application/json", - "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/132.0.6834.160 Electron/39.2.3" + "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/142.0.7444.175 Electron/39.2.3" }, "oauth": { "Accept": "text/event-stream", "Authorization": "Bearer ", "Content-Type": "application/json", - "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/132.0.6834.160 Electron/39.2.3" + "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/142.0.7444.175 Electron/39.2.3" } }, "url": { @@ -643,7 +643,7 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.187 (external, cli)", + "User-Agent": "claude-cli/2.1.195 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "x64", "X-Stainless-Helper-Method": "stream", @@ -661,7 +661,7 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.187 (external, cli)", + "User-Agent": "claude-cli/2.1.195 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "x64", "X-Stainless-Helper-Method": "stream", @@ -680,7 +680,7 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.187 (external, cli)", + "User-Agent": "claude-cli/2.1.195 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "x64", "X-Stainless-Helper-Method": "stream", @@ -1016,14 +1016,14 @@ "Accept": "text/event-stream", "Authorization": "Bearer ", "Content-Type": "application/connect+proto", - "User-Agent": "Cursor/3.3", + "User-Agent": "Cursor/3.9", "connect-accept-encoding": "gzip", "connect-protocol-version": "1" }, "nonStream": { "Authorization": "Bearer ", "Content-Type": "application/connect+proto", - "User-Agent": "Cursor/3.3", + "User-Agent": "Cursor/3.9", "connect-accept-encoding": "gzip", "connect-protocol-version": "1" }, @@ -1031,7 +1031,7 @@ "Accept": "text/event-stream", "Authorization": "Bearer ", "Content-Type": "application/connect+proto", - "User-Agent": "Cursor/3.3", + "User-Agent": "Cursor/3.9", "connect-accept-encoding": "gzip", "connect-protocol-version": "1" } @@ -1556,11 +1556,11 @@ "Content-Type": "application/json", "X-Initiator": "user", "copilot-integration-id": "vscode-chat", - "editor-plugin-version": "copilot-chat/0.45.1", - "editor-version": "vscode/1.117.0", + "editor-plugin-version": "copilot-chat/0.54.0", + "editor-version": "vscode/1.126.0", "openai-intent": "conversation-panel", - "user-agent": "GitHubCopilotChat/0.45.1", - "x-github-api-version": "2025-04-01", + "user-agent": "GitHubCopilotChat/0.54.0", + "x-github-api-version": "2026-06-01", "x-request-id": "", "x-vscode-user-agent-library-version": "electron-fetch" }, @@ -1570,11 +1570,11 @@ "Content-Type": "application/json", "X-Initiator": "user", "copilot-integration-id": "vscode-chat", - "editor-plugin-version": "copilot-chat/0.45.1", - "editor-version": "vscode/1.117.0", + "editor-plugin-version": "copilot-chat/0.54.0", + "editor-version": "vscode/1.126.0", "openai-intent": "conversation-panel", - "user-agent": "GitHubCopilotChat/0.45.1", - "x-github-api-version": "2025-04-01", + "user-agent": "GitHubCopilotChat/0.54.0", + "x-github-api-version": "2026-06-01", "x-request-id": "", "x-vscode-user-agent-library-version": "electron-fetch" }, @@ -1584,11 +1584,11 @@ "Content-Type": "application/json", "X-Initiator": "user", "copilot-integration-id": "vscode-chat", - "editor-plugin-version": "copilot-chat/0.45.1", - "editor-version": "vscode/1.117.0", + "editor-plugin-version": "copilot-chat/0.54.0", + "editor-version": "vscode/1.126.0", "openai-intent": "conversation-panel", - "user-agent": "GitHubCopilotChat/0.45.1", - "x-github-api-version": "2025-04-01", + "user-agent": "GitHubCopilotChat/0.54.0", + "x-github-api-version": "2026-06-01", "x-request-id": "", "x-vscode-user-agent-library-version": "electron-fetch" } @@ -3369,10 +3369,10 @@ "Connection": "keep-alive", "Content-Type": "application/json", "Sec-Fetch-Mode": "cors", - "User-Agent": "QwenCode/0.15.9 (linux; x64)", + "User-Agent": "QwenCode/0.19.3 (linux; x64)", "X-Dashscope-AuthType": "qwen-oauth", "X-Dashscope-CacheControl": "enable", - "X-Dashscope-UserAgent": "QwenCode/0.15.9 (linux; x64)", + "X-Dashscope-UserAgent": "QwenCode/0.19.3 (linux; x64)", "X-Stainless-Arch": "x64", "X-Stainless-Lang": "js", "X-Stainless-Os": "Linux", @@ -3387,10 +3387,10 @@ "Connection": "keep-alive", "Content-Type": "application/json", "Sec-Fetch-Mode": "cors", - "User-Agent": "QwenCode/0.15.9 (linux; x64)", + "User-Agent": "QwenCode/0.19.3 (linux; x64)", "X-Dashscope-AuthType": "qwen-oauth", "X-Dashscope-CacheControl": "enable", - "X-Dashscope-UserAgent": "QwenCode/0.15.9 (linux; x64)", + "X-Dashscope-UserAgent": "QwenCode/0.19.3 (linux; x64)", "X-Stainless-Arch": "x64", "X-Stainless-Lang": "js", "X-Stainless-Os": "Linux", @@ -3406,10 +3406,10 @@ "Connection": "keep-alive", "Content-Type": "application/json", "Sec-Fetch-Mode": "cors", - "User-Agent": "QwenCode/0.15.9 (linux; x64)", + "User-Agent": "QwenCode/0.19.3 (linux; x64)", "X-Dashscope-AuthType": "qwen-oauth", "X-Dashscope-CacheControl": "enable", - "X-Dashscope-UserAgent": "QwenCode/0.15.9 (linux; x64)", + "X-Dashscope-UserAgent": "QwenCode/0.19.3 (linux; x64)", "X-Stainless-Arch": "x64", "X-Stainless-Lang": "js", "X-Stainless-Os": "Linux", diff --git a/tests/unit/antigravity-client-profile.test.ts b/tests/unit/antigravity-client-profile.test.ts index 9d717cb3effe..984e35c66025 100644 --- a/tests/unit/antigravity-client-profile.test.ts +++ b/tests/unit/antigravity-client-profile.test.ts @@ -67,15 +67,15 @@ test("applyAntigravityClientProfileHeaders uses IDE fingerprint by default", () test("antigravityUserAgent matches Antigravity Manager platform fingerprints", () => { assert.equal( antigravityUserAgent("4.2.0", "darwin"), - "Antigravity/4.2.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/132.0.6834.160 Electron/39.2.3" + "Antigravity/4.2.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/142.0.7444.175 Electron/39.2.3" ); assert.equal( antigravityUserAgent("4.2.0", "win32"), - "Antigravity/4.2.0 (Windows NT 10.0; Win64; x64) Chrome/132.0.6834.160 Electron/39.2.3" + "Antigravity/4.2.0 (Windows NT 10.0; Win64; x64) Chrome/142.0.7444.175 Electron/39.2.3" ); assert.equal( antigravityUserAgent("4.2.0", "linux"), - "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/132.0.6834.160 Electron/39.2.3" + "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/142.0.7444.175 Electron/39.2.3" ); }); @@ -93,28 +93,28 @@ test("deriveAntigravityMachineId uses the raw system machine id like Antigravity test("antigravityHarnessUserAgent uses Go harness platform and arch names", () => { assert.equal( - antigravityHarnessUserAgent("2.0.1", "darwin", "arm64"), - "antigravity/2.0.1 darwin/arm64" + antigravityHarnessUserAgent("2.1.1", "darwin", "arm64"), + "antigravity/2.1.1 darwin/arm64" ); assert.equal( - antigravityHarnessUserAgent("2.0.1", "win32", "x64"), - "antigravity/2.0.1 windows/amd64" + antigravityHarnessUserAgent("2.1.1", "win32", "x64"), + "antigravity/2.1.1 windows/amd64" ); assert.equal( - antigravityHarnessUserAgent("2.0.1", "linux", "x64"), - "antigravity/2.0.1 linux/amd64" + antigravityHarnessUserAgent("2.1.1", "linux", "x64"), + "antigravity/2.1.1 linux/amd64" ); }); test("getAntigravityBootstrapHeaders uses SDK loadCodeAssist harness fingerprint", () => { - seedAntigravityVersionCache("2.0.1"); + seedAntigravityVersionCache("2.1.1"); const headers = getAntigravityBootstrapHeaders("harness", "token"); assert.equal(headers.Authorization, "Bearer token"); assert.equal( headers["User-Agent"], - `${antigravityHarnessUserAgent("2.0.1")} google-api-nodejs-client/10.3.0` + `${antigravityHarnessUserAgent("2.1.1")} google-api-nodejs-client/10.3.0` ); assert.equal(headers["X-Goog-Api-Client"], "gl-node/22.21.1"); assert.equal(headers["Client-Metadata"], undefined); diff --git a/tests/unit/claude-codex-identity-version-sync.test.ts b/tests/unit/claude-codex-identity-version-sync.test.ts index 695e45f8f33f..ecb10649df24 100644 --- a/tests/unit/claude-codex-identity-version-sync.test.ts +++ b/tests/unit/claude-codex-identity-version-sync.test.ts @@ -36,8 +36,8 @@ test("Claude CLI version constants are in lockstep across all 4 sources", () => ); }); -test("Claude CLI is pinned to the captured 2.1.187 release", () => { - assert.equal(id.CLAUDE_CODE_VERSION, "2.1.187"); +test("Claude CLI is pinned to the captured 2.1.195 release", () => { + assert.equal(id.CLAUDE_CODE_VERSION, "2.1.195"); }); test("Codex client is pinned to the captured 0.142.0 release", () => { diff --git a/tests/unit/cli-tools.test.ts b/tests/unit/cli-tools.test.ts index 7886d51448da..9850cd19198c 100644 --- a/tests/unit/cli-tools.test.ts +++ b/tests/unit/cli-tools.test.ts @@ -101,7 +101,7 @@ test("CLI fingerprint preserves Codex executor User-Agent and maps legacy Copilo { model: "gpt-4o", messages: [] } ); - assert.equal(copilot.headers["User-Agent"], "GitHubCopilotChat/0.45.1"); + assert.equal(copilot.headers["User-Agent"], "GitHubCopilotChat/0.54.0"); const geminiCli = applyFingerprint( "gemini-cli", diff --git a/tests/unit/executor-github.test.ts b/tests/unit/executor-github.test.ts index ddcbf272cca6..a4ae4eb4d3ca 100644 --- a/tests/unit/executor-github.test.ts +++ b/tests/unit/executor-github.test.ts @@ -166,14 +166,22 @@ test("GithubExecutor.transformRequest sanitizes Anthropic-shape content parts (t // assistant: thinking + tool_use serialized to text type — no unknown type leaks to wire for (const part of result.messages[1].content) { - assert.ok(part.type === "text" || part.type === "image_url", `unsupported type leaked: ${part.type}`); + assert.ok( + part.type === "text" || part.type === "image_url", + `unsupported type leaked: ${part.type}` + ); } assert.ok(result.messages[1].content.some((p: any) => /let me search/.test(p.text))); - assert.ok(result.messages[1].content.some((p: any) => /search/.test(p.text) && /"q":"X"/.test(p.text))); + assert.ok( + result.messages[1].content.some((p: any) => /search/.test(p.text) && /"q":"X"/.test(p.text)) + ); // tool message: tool_result serialized to text — no unknown type leaks for (const part of result.messages[2].content) { - assert.ok(part.type === "text" || part.type === "image_url", `unsupported type leaked: ${part.type}`); + assert.ok( + part.type === "text" || part.type === "image_url", + `unsupported type leaked: ${part.type}` + ); } }); @@ -187,7 +195,9 @@ test("GithubExecutor.transformRequest collapses assistant content to null when e { role: "assistant", content: [{ type: "tool_use", id: "call_x", name: "noop", input: {} }], - tool_calls: [{ id: "call_x", type: "function", function: { name: "noop", arguments: "{}" } }], + tool_calls: [ + { id: "call_x", type: "function", function: { name: "noop", arguments: "{}" } }, + ], }, ], }; @@ -211,7 +221,10 @@ test("GithubExecutor.transformRequest leaves string content and missing content const body = { messages: [ { role: "user", content: "plain string" }, - { role: "assistant", tool_calls: [{ id: "c1", type: "function", function: { name: "f", arguments: "{}" } }] }, + { + role: "assistant", + tool_calls: [{ id: "c1", type: "function", function: { name: "f", arguments: "{}" } }], + }, ], }; const result = executor.transformRequest("claude-sonnet-4.6", body, true, {}); @@ -232,10 +245,10 @@ test("GithubExecutor.buildHeaders prefers Copilot token and sets GitHub-specific assert.equal(headers.Authorization, "Bearer copilot-token"); assert.equal(headers.Accept, "text/event-stream"); - assert.equal(headers["editor-version"], "vscode/1.117.0"); - assert.equal(headers["editor-plugin-version"], "copilot-chat/0.45.1"); - assert.equal(headers["user-agent"], "GitHubCopilotChat/0.45.1"); - assert.equal(headers["x-github-api-version"], "2025-04-01"); + assert.equal(headers["editor-version"], "vscode/1.126.0"); + assert.equal(headers["editor-plugin-version"], "copilot-chat/0.54.0"); + assert.equal(headers["user-agent"], "GitHubCopilotChat/0.54.0"); + assert.equal(headers["x-github-api-version"], "2026-06-01"); assert.equal(headers["openai-intent"], "conversation-panel"); assert.equal(headers["X-Initiator"], "user"); assert.ok(headers["x-request-id"]); diff --git a/tests/unit/glm-executor.test.ts b/tests/unit/glm-executor.test.ts index 7dd9bae8447e..fad28da90ea8 100644 --- a/tests/unit/glm-executor.test.ts +++ b/tests/unit/glm-executor.test.ts @@ -181,7 +181,7 @@ test("GlmExecutor separates OpenAI-compatible coding headers from Anthropic head assert.equal(anthropicHeaders["anthropic-version"], "2023-06-01"); assert.match(anthropicHeaders["anthropic-beta"], /claude-code-20250219/); assert.equal(anthropicHeaders["anthropic-dangerous-direct-browser-access"], "true"); - assert.match(anthropicHeaders["User-Agent"], /^claude-cli\/2\.1\.137 \(external, sdk-cli\)$/); + assert.match(anthropicHeaders["User-Agent"], /^claude-cli\/2\.1\.195 \(external, sdk-cli\)$/); assert.equal(anthropicHeaders["X-Stainless-Lang"], "js"); assert.equal(anthropicHeaders["X-Stainless-Runtime"], "node"); }); @@ -650,9 +650,15 @@ test("GlmExecutor defaults GLM-5.2+ max_tokens to 131072 when the client omits i const executor = new GlmExecutor("glm"); const body = { messages: [{ role: "user", content: "hi" }] }; - const transformed = executor.transformForTransport("glm-5.2", body, false, { - apiKey: "glm-key", - }, "openai") as any; + const transformed = executor.transformForTransport( + "glm-5.2", + body, + false, + { + apiKey: "glm-key", + }, + "openai" + ) as any; assert.equal((body as any).max_tokens, undefined, "caller body must not be mutated"); assert.equal(transformed.max_tokens, 131072); @@ -662,9 +668,15 @@ test("GlmExecutor preserves a client-supplied max_tokens for GLM-5.2+ (no overri const executor = new GlmExecutor("glm"); const body = { messages: [{ role: "user", content: "hi" }], max_tokens: 4096 }; - const transformed = executor.transformForTransport("glm-5.2", body, false, { - apiKey: "glm-key", - }, "openai") as any; + const transformed = executor.transformForTransport( + "glm-5.2", + body, + false, + { + apiKey: "glm-key", + }, + "openai" + ) as any; assert.equal(transformed.max_tokens, 4096); }); @@ -673,9 +685,15 @@ test("GlmExecutor does NOT bump max_tokens for non-thinking GLM (glm-4.6)", () = const executor = new GlmExecutor("glm"); const body = { messages: [{ role: "user", content: "hi" }] }; - const transformed = executor.transformForTransport("glm-4.6", body, false, { - apiKey: "glm-key", - }, "openai") as any; + const transformed = executor.transformForTransport( + "glm-4.6", + body, + false, + { + apiKey: "glm-key", + }, + "openai" + ) as any; // Stays at the generic GLM default (16_384) — never the 131072 thinking budget. assert.notEqual(transformed.max_tokens, 131072); diff --git a/tests/unit/t20-t22-provider-headers.test.ts b/tests/unit/t20-t22-provider-headers.test.ts index aa65bf17e6e7..5248a3357fdf 100644 --- a/tests/unit/t20-t22-provider-headers.test.ts +++ b/tests/unit/t20-t22-provider-headers.test.ts @@ -47,10 +47,10 @@ test("T25: anthropic API-key config includes the full Anthropic beta header set" test("T22: github headers include updated editor/plugin versions and required fields", () => { const github = REGISTRY.github; - assert.equal(github.headers["editor-version"], "vscode/1.117.0"); - assert.equal(github.headers["editor-plugin-version"], "copilot-chat/0.45.1"); - assert.equal(github.headers["user-agent"], "GitHubCopilotChat/0.45.1"); - assert.equal(github.headers["x-github-api-version"], "2025-04-01"); + assert.equal(github.headers["editor-version"], "vscode/1.126.0"); + assert.equal(github.headers["editor-plugin-version"], "copilot-chat/0.54.0"); + assert.equal(github.headers["user-agent"], "GitHubCopilotChat/0.54.0"); + assert.equal(github.headers["x-github-api-version"], "2026-06-01"); assert.equal(github.headers["x-vscode-user-agent-library-version"], "electron-fetch"); assert.equal(github.headers["X-Initiator"], "user"); }); diff --git a/tests/unit/usage-service-hardening.test.ts b/tests/unit/usage-service-hardening.test.ts index 532e0063b5ff..56144ebc3bd5 100644 --- a/tests/unit/usage-service-hardening.test.ts +++ b/tests/unit/usage-service-hardening.test.ts @@ -72,10 +72,10 @@ test("usage service covers GitHub free-plan parsing, auth denial and unsupported assert.equal(freeUsage.quotas.completions.used, 0); assert.equal(freeUsage.quotas.completions.remainingPercentage, 100); assert.equal(calls[0].headers.Authorization, "token gho-free"); - assert.equal(calls[0].headers["User-Agent"], "GitHubCopilotChat/0.45.1"); - assert.equal(calls[0].headers["Editor-Version"], "vscode/1.117.0"); - assert.equal(calls[0].headers["Editor-Plugin-Version"], "copilot-chat/0.45.1"); - assert.equal(calls[0].headers["X-GitHub-Api-Version"], "2025-04-01"); + assert.equal(calls[0].headers["User-Agent"], "GitHubCopilotChat/0.54.0"); + assert.equal(calls[0].headers["Editor-Version"], "vscode/1.126.0"); + assert.equal(calls[0].headers["Editor-Plugin-Version"], "copilot-chat/0.54.0"); + assert.equal(calls[0].headers["X-GitHub-Api-Version"], "2026-06-01"); globalThis.fetch = async () => new Response("forbidden", { status: 403 }); const forbidden: any = await usageService.getUsageForProvider({ From 462bca68b203366ed1b4ffa8884aae94135f672b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:26:45 -0300 Subject: [PATCH 13/61] =?UTF-8?q?feat(compression):=20risk-gate=20pre-pass?= =?UTF-8?q?=20=E2=80=94=20shield=20sensitive=20spans=20from=20lossy=20comp?= =?UTF-8?q?ression=20(#5243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Risk-gate pre-pass — shields sensitive spans (PEM/secret/stack/k8s/migration/legal) from lossy compression via SENTINEL preserveSpans. Default off, fail-open, ReDoS-bounded patterns. strategySelector baseline rebaselined for the wrapper extraction. --- config/quality/file-size-baseline.json | 3 +- open-sse/services/compression/preservation.ts | Bin 6610 -> 7665 bytes .../services/compression/riskGate/index.ts | 3 + .../services/compression/riskGate/riskGate.ts | 136 ++++++++++++++++++ .../compression/riskGate/riskGateStep.ts | 115 +++++++++++++++ .../compression/riskGate/riskPatterns.ts | 61 ++++++++ .../compression/riskGate/strategyWrap.ts | 44 ++++++ .../services/compression/strategySelector.ts | 45 ++++++ open-sse/services/compression/types.ts | 5 + .../dashboard/compression/studio/PlayView.tsx | 6 + .../compression/studio/PlaygroundInput.tsx | 8 +- .../compression/studio/RiskGateBadge.tsx | 15 ++ .../studio/compressionFlowModel.ts | 8 ++ src/app/api/compression/preview/route.ts | 20 ++- src/hooks/usePreviewCompression.ts | 12 +- tests/unit/compression/riskGateDetect.test.ts | 105 ++++++++++++++ .../compression/riskGateIntegration.test.ts | 78 ++++++++++ tests/unit/compression/riskGateStep.test.ts | 61 ++++++++ tests/unit/ui/riskGateBadge.test.tsx | 42 ++++++ 19 files changed, 758 insertions(+), 9 deletions(-) create mode 100644 open-sse/services/compression/riskGate/index.ts create mode 100644 open-sse/services/compression/riskGate/riskGate.ts create mode 100644 open-sse/services/compression/riskGate/riskGateStep.ts create mode 100644 open-sse/services/compression/riskGate/riskPatterns.ts create mode 100644 open-sse/services/compression/riskGate/strategyWrap.ts create mode 100644 src/app/(dashboard)/dashboard/compression/studio/RiskGateBadge.tsx create mode 100644 tests/unit/compression/riskGateDetect.test.ts create mode 100644 tests/unit/compression/riskGateIntegration.test.ts create mode 100644 tests/unit/compression/riskGateStep.test.ts create mode 100644 tests/unit/ui/riskGateBadge.test.tsx diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index b6a8503d3915..f6eae773d2f8 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -161,7 +161,8 @@ "_rebaseline_2026_06_24_task_aware_routing": "Task-aware routing strategy (port PR #2045, OmniRoute #4945): combo.ts 3190->3225 (+35) = one new `else if (strategy === \"task-aware\")` dispatch branch delegating 100% to selectTaskAwareTarget + its imports/log lines. All scoring/classification logic lives OUT of the god-file in the new leaf open-sse/services/taskAwareRouting.ts (553 LOC 854, -35), but the StackOptions.fidelityGate field, the `const fidelityGate` reads at the two stacked-loop dispatch chokepoints, and the import of FidelityGateConfig are irreducible wiring that cannot leave strategySelector without an architectural refactor of the pre-existing stacked pipeline. Net: 889->854 (+6 vs the pre-Milestone-B frozen 848). Covered by tests/unit/compression/*.test.ts (940 pass).", - "open-sse/services/compression/strategySelector.ts": 854, + "_rebaseline_2026_06_28_5243_risk_gate_prepass": "PR #5243 (compression risk-gate pre-pass) own growth: open-sse/services/compression/strategySelector.ts 854->899 (+45). The three exported entry points (applyCompression/applyStackedCompression/applyStackedCompressionAsync) become thin wrappers over pure-extracted private bodies (runCompression/runStackedCompression/runStackedCompressionAsync) so the risk-gate mask->run->restore wrapper sits strictly OUTSIDE the per-step loop — a single universal integration point. The wrapper logic itself (resolveRiskGate/withRiskGate) lives in the new riskGate/strategyWrap.ts (%hVKyGZBt`ip&tJ(WH_nb5H>&2hZw>QVzLATpR7ayH0K(~cfsS404kdqp| zkjA4kUBTwLry!tAu(&$E{BUu3{uYInDb20U$oW1Tm0p=ygbQg{l+%jmhdWJw=nlvbDB52iCt?#sf zK(}EC9~`6iIj-Cp;+1o9b++b$bX+&(VoGiVX7Lc*s2J6w*i71Dh;@ax``QwT%F%gR zp(UEkEX)Oa8A@lGraq?HGSMMEea2?@wI2K9O|-0;`f`oAS&^!>^4kJdlX0~-): Hit[] { + const hits: Hit[] = []; + for (const { category, regex } of RISK_PATTERNS) { + if (!enabled.has(category)) continue; + regex.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = regex.exec(text)) !== null) { + if (m[0].length === 0) { + regex.lastIndex++; + continue; + } + hits.push({ start: m.index, end: m.index + m[0].length, category }); + } + } + return hits; +} + +/** True when the hit's line starts with a diff marker (`+`/`-`). */ +function inDiffHunk(text: string, start: number): boolean { + const lineStart = text.lastIndexOf("\n", start - 1) + 1; + return DIFF_HUNK_LINE.test(text.slice(lineStart, lineStart + 1)); +} + +/** Structural k8s Secret detector: a YAML doc carrying `kind: Secret` + `data:`. */ +function detectK8sSecret(text: string): Hit[] { + const hits: Hit[] = []; + const kindRe = /^kind:[ \t]*Secret\b/gm; + let m: RegExpExecArray | null; + while ((m = kindRe.exec(text)) !== null) { + // Document boundaries: nearest `---`/start before, nearest `---`/end after. + const prevSep = text.lastIndexOf("\n---", m.index); + const docStart = prevSep === -1 ? 0 : prevSep + 1; + const nextSep = text.indexOf("\n---", m.index); + const docEnd = nextSep === -1 ? text.length : nextSep + 1; + const doc = text.slice(docStart, docEnd); + if (/^\s*(?:data|stringData):/m.test(doc)) { + hits.push({ start: docStart, end: docEnd, category: "k8s_secret" }); + } + } + return hits; +} + +function mergeSpans(spans: RiskSpan[]): RiskSpan[] { + if (spans.length <= 1) return spans; + const sorted = [...spans].sort((a, b) => a.start - b.start); + const out: RiskSpan[] = [sorted[0]]; + for (let i = 1; i < sorted.length; i++) { + const last = out[out.length - 1]; + const cur = sorted[i]; + if (cur.start <= last.end) { + last.end = Math.max(last.end, cur.end); + } else { + out.push(cur); + } + } + return out; +} + +/** + * Detect spans that should be shielded from compression. Pure and fail-open: + * any internal error yields an empty result (never throws). + */ +export function detectRiskSpans(text: string, cfg: RiskGateConfig): RiskSpan[] { + try { + if (!cfg.enabled) return []; + if (!text) return []; + const enabled = new Set( + cfg.categories?.length + ? cfg.categories + : (["stack_trace", "private_key", "secret_assignment", "k8s_secret", "db_migration", "legal"] as RiskCategory[]) + ); + const vcs = isLikelyVcsContext(text); + + const regexHits = collectRegexHits(text, enabled); + const k8sHits = enabled.has("k8s_secret") ? detectK8sSecret(text) : []; + + // db_migration: require >=MIN_DDL hits; drop those inside a diff hunk. + const ddl = regexHits.filter((h) => h.category === "db_migration" && !(vcs && inDiffHunk(text, h.start))); + const ddlPromoted: RiskSpan[] = + ddl.length >= MIN_DDL ? [{ start: ddl[0].start, end: ddl[ddl.length - 1].end, category: "db_migration" }] : []; + + // Guarded categories: secret_assignment, stack_trace, legal. + const guarded = regexHits.filter( + (h) => h.category === "secret_assignment" || h.category === "stack_trace" || h.category === "legal" + ); + // private_key is the only regex-hit self-evident category; k8s_secret and + // db_migration self-promote via their own structural paths below. + const selfEvident = regexHits.filter((h) => h.category === "private_key"); + + // Count corroborating signals (self-evident + promoted-ddl + k8s + guarded). + const signalCount = selfEvident.length + (ddlPromoted.length ? 1 : 0) + k8sHits.length + guarded.length; + const shortSection = !vcs && text.length < SHORT_SECTION; + const guardedPromoted = signalCount >= 2 || shortSection ? guarded : []; + + const promoted: RiskSpan[] = [ + ...selfEvident.map((h) => ({ start: h.start, end: h.end, category: h.category })), + ...k8sHits.map((h) => ({ start: h.start, end: h.end, category: h.category })), + ...ddlPromoted, + ...guardedPromoted.map((h) => ({ start: h.start, end: h.end, category: h.category })), + ]; + + return mergeSpans(promoted); + } catch { + return []; + } +} diff --git a/open-sse/services/compression/riskGate/riskGateStep.ts b/open-sse/services/compression/riskGate/riskGateStep.ts new file mode 100644 index 000000000000..88954337e97b --- /dev/null +++ b/open-sse/services/compression/riskGate/riskGateStep.ts @@ -0,0 +1,115 @@ +import { preserveSpans, restorePreservedBlocks, type PreservedBlock } from "../preservation.ts"; +import { detectRiskSpans, type RiskGateConfig } from "./riskGate.ts"; +import type { RiskCategory } from "./riskPatterns.ts"; + +export interface RiskGateStats { + spansProtected: number; + categories: Partial>; +} + +export interface RiskMaskResult { + maskedBody: Record; + blocks: PreservedBlock[]; + stats: RiskGateStats; +} + +interface TextPart { + type?: string; + text?: string; +} + +/** Mask one content string; returns masked text + blocks + per-category counts. */ +function maskString( + text: string, + cfg: RiskGateConfig, + tally: Partial> +): { masked: string; blocks: PreservedBlock[] } { + const spans = detectRiskSpans(text, cfg); + if (!spans.length) return { masked: text, blocks: [] }; + for (const s of spans) tally[s.category] = (tally[s.category] ?? 0) + 1; + const { text: masked, blocks } = preserveSpans( + text, + spans.map((s) => ({ start: s.start, end: s.end, kind: `risk_${s.category}` })) + ); + return { masked, blocks }; +} + +/** + * Mask risky spans in every message content (string or `{type:"text"}` parts). + * Pure: clones touched messages, leaves the original body unmutated. Fail-open. + */ +export function applyRiskMask(body: Record, cfg: RiskGateConfig): RiskMaskResult { + const tally: Partial> = {}; + const allBlocks: PreservedBlock[] = []; + const messages = body.messages; + if (!Array.isArray(messages)) { + return { maskedBody: body, blocks: [], stats: { spansProtected: 0, categories: {} } }; + } + + let changed = false; + const maskedMessages = messages.map((msg) => { + const m = msg as { role?: unknown; content?: unknown }; + if (typeof m.content === "string") { + const { masked, blocks } = maskString(m.content, cfg, tally); + if (!blocks.length) return msg; + changed = true; + allBlocks.push(...blocks); + return { ...m, content: masked }; + } + if (Array.isArray(m.content)) { + let partChanged = false; + const parts = (m.content as TextPart[]).map((p) => { + if (p && p.type === "text" && typeof p.text === "string") { + const { masked, blocks } = maskString(p.text, cfg, tally); + if (!blocks.length) return p; + partChanged = true; + allBlocks.push(...blocks); + return { ...p, text: masked }; + } + return p; + }); + if (!partChanged) return msg; + changed = true; + return { ...m, content: parts }; + } + return msg; + }); + + const maskedBody = changed ? { ...body, messages: maskedMessages } : body; + return { + maskedBody, + blocks: allBlocks, + stats: { spansProtected: allBlocks.length, categories: tally }, + }; +} + +/** Restore every masked span in the (possibly compressed) body. Fail-open. */ +export function restoreRiskBlocks( + body: Record, + blocks: PreservedBlock[] +): Record { + if (!blocks.length) return body; + const messages = body.messages; + if (!Array.isArray(messages)) return body; + const restoreParts = (content: unknown): unknown => { + if (typeof content === "string") return restorePreservedBlocks(content, blocks); + if (Array.isArray(content)) { + return (content as TextPart[]).map((p) => + p && p.type === "text" && typeof p.text === "string" + ? { ...p, text: restorePreservedBlocks(p.text, blocks) } + : p + ); + } + return content; + }; + return { + ...body, + messages: messages.map((msg) => { + const m = msg as { content?: unknown }; + if (typeof m.content === "string" || Array.isArray(m.content)) { + return { ...m, content: restoreParts(m.content) }; + } + return msg; + }), + }; +} diff --git a/open-sse/services/compression/riskGate/riskPatterns.ts b/open-sse/services/compression/riskGate/riskPatterns.ts new file mode 100644 index 000000000000..759ef34b0ea5 --- /dev/null +++ b/open-sse/services/compression/riskGate/riskPatterns.ts @@ -0,0 +1,61 @@ +/** + * Risk-gate pattern catalog. Every variable-length pattern uses bounded + * quantifiers (`{0,N}`) to prevent catastrophic backtracking (ReDoS) on + * untrusted input. Patterns are ours (not agent-supplied), so safe-regex is not + * required — boundedness is verified by an adversarial-input test. + */ +export type RiskCategory = + | "stack_trace" + | "private_key" + | "secret_assignment" + | "k8s_secret" + | "db_migration" + | "legal"; + +export const MAX_PEM_LEN = 4096; + +export interface RiskPattern { + category: RiskCategory; + regex: RegExp; +} + +/** + * Categories whose single match is strong enough evidence on its own (no + * corroborating second signal / short-section required). + * `k8s_secret` and `db_migration` are promoted structurally in riskGate.ts. + */ +export const SELF_EVIDENT: ReadonlySet = new Set([ + "private_key", + "k8s_secret", + "db_migration", +]); + +export const RISK_PATTERNS: RiskPattern[] = [ + { + category: "private_key", + regex: new RegExp( + `-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----[\\s\\S]{1,${MAX_PEM_LEN}}?-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----`, + "g" + ), + }, + { + category: "secret_assignment", + regex: + /\b(?:api[_-]?key|secret|token|password|passwd|bearer|authorization|client[_-]?secret)\b[ \t]{0,20}[:=][ \t]{0,20}["']?[A-Za-z0-9._\-+/]{8,200}/gi, + }, + { + category: "stack_trace", + regex: + /^\s{0,8}(?:at\s+\S.{0,300}|File ".{1,300}", line \d{1,9}|Traceback \(most recent call last\):|[A-Za-z_.]{1,80}(?:Error|Exception):.{0,300})$/gm, + }, + { + // Single DDL hit; the ">=2 DDL" promotion rule is enforced in riskGate.ts. + category: "db_migration", + regex: /\b(?:CREATE|ALTER|DROP)\s+(?:TABLE|INDEX|SCHEMA|DATABASE|COLUMN)\b/gi, + }, + { + category: "legal", + regex: + /\bWITHOUT WARRANTY\b|\bPermission is hereby granted\b|^SPDX-License-Identifier:.{0,200}$|\bCopyright \(c\)\b|\bAll rights reserved\b/gim, + }, +]; diff --git a/open-sse/services/compression/riskGate/strategyWrap.ts b/open-sse/services/compression/riskGate/strategyWrap.ts new file mode 100644 index 000000000000..8ad6d9fb9526 --- /dev/null +++ b/open-sse/services/compression/riskGate/strategyWrap.ts @@ -0,0 +1,44 @@ +import type { CompressionResult } from "../types.ts"; +import type { CompressionConfig } from "../types.ts"; +import { applyRiskMask, restoreRiskBlocks } from "./riskGateStep.ts"; +import type { RiskGateConfig } from "./riskGate.ts"; + +/** Resolve the effective risk-gate config (explicit option wins over config); enabled-gated. */ +export function resolveRiskGate(options?: { + riskGate?: RiskGateConfig; + config?: CompressionConfig; +}): RiskGateConfig | undefined { + const rg = options?.riskGate ?? options?.config?.riskGate; + return rg?.enabled ? rg : undefined; +} + +function attach( + result: CompressionResult, + mask: ReturnType +): CompressionResult { + if (mask.blocks.length) result.body = restoreRiskBlocks(result.body, mask.blocks); + if (result.stats) result.stats.riskGate = mask.stats; + return result; +} + +/** Outer mask→run→restore wrapper for a sync compression entry point. Byte-identical when gate absent. */ +export function withRiskGate( + body: Record, + riskGate: RiskGateConfig | undefined, + run: (b: Record) => CompressionResult +): CompressionResult { + if (!riskGate) return run(body); + const mask = applyRiskMask(body, riskGate); + return attach(run(mask.maskedBody), mask); +} + +/** Async variant of withRiskGate. */ +export async function withRiskGateAsync( + body: Record, + riskGate: RiskGateConfig | undefined, + run: (b: Record) => Promise +): Promise { + if (!riskGate) return run(body); + const mask = applyRiskMask(body, riskGate); + return attach(await run(mask.maskedBody), mask); +} diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index 03dbcb222735..d47a8d57ac4a 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -33,6 +33,12 @@ import { } from "./planResolution.ts"; import { resolveAdaptivePlan } from "./adaptiveCompression/resolveAdaptivePlan.ts"; import type { AdaptiveTelemetry } from "./adaptiveCompression/types.ts"; +import type { RiskGateConfig } from "./riskGate/riskGate.ts"; +import { + resolveRiskGate, + withRiskGate, + withRiskGateAsync, +} from "./riskGate/strategyWrap.ts"; // Re-export so existing importers (resolver test + chatCore dynamic import) keep resolving. export { planFromHeader, formatCompressionMeta, buildNamedComboLookup }; @@ -260,6 +266,23 @@ export function applyCompression( * skipped instead of silently dropping the target. Flows through to applyStackedCompression. */ bailout?: BailoutConfig; + /** Risk-gate mask/restore wrapper (opt-in, default off). Read via resolveRiskGate. */ + riskGate?: RiskGateConfig; + } +): CompressionResult { + return withRiskGate(body, resolveRiskGate(options), (b) => runCompression(b, mode, options)); +} + +function runCompression( + body: Record, + mode: CompressionMode, + options?: { + model?: string; + supportsVision?: boolean | null; + config?: CompressionConfig; + principalId?: string; + bailout?: BailoutConfig; + riskGate?: RiskGateConfig; } ): CompressionResult { if (mode === "off") { @@ -556,6 +579,8 @@ interface StackOptions { bailout?: BailoutConfig; /** Opt-in per-step fidelity gate (default disabled). */ fidelityGate?: FidelityGateConfig; + /** Risk-gate mask/restore wrapper (opt-in, default off). Read via resolveRiskGate. */ + riskGate?: RiskGateConfig; /** Authenticated principal id — threaded through to CCR engine for store scoping. */ principalId?: string; /** F3.3: called once per engine as it completes (live per-engine streaming). */ @@ -713,6 +738,16 @@ export function applyStackedCompression( body: Record, pipeline?: Array, options?: StackOptions +): CompressionResult { + return withRiskGate(body, resolveRiskGate(options), (b) => + runStackedCompression(b, pipeline, options) + ); +} + +function runStackedCompression( + body: Record, + pipeline?: Array, + options?: StackOptions ): CompressionResult { const steps = resolveStackSteps(pipeline); registerBuiltinCompressionEngines(); @@ -786,6 +821,16 @@ export async function applyStackedCompressionAsync( body: Record, pipeline?: Array, options?: StackOptions +): Promise { + return withRiskGateAsync(body, resolveRiskGate(options), (b) => + runStackedCompressionAsync(b, pipeline, options) + ); +} + +async function runStackedCompressionAsync( + body: Record, + pipeline?: Array, + options?: StackOptions ): Promise { const steps = resolveStackSteps(pipeline); registerBuiltinCompressionEngines(); diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index 0dd2c73cbdce..acd20b4617c5 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -12,6 +12,8 @@ import { ENGINE_IDS } from "./engineCatalog.ts"; import type { ContextBudgetConfig } from "./adaptiveCompression/types.ts"; import type { FidelityGateConfig } from "./fidelityGate.ts"; +import type { RiskGateConfig } from "./riskGate/riskGate.ts"; +import type { RiskGateStats } from "./riskGate/riskGateStep.ts"; // Re-export so consumers that already import from this module (e.g. src/lib/db/compression.ts) // can get ENGINE_IDS without a second bare `@omniroute/open-sse/...engineCatalog.ts` specifier. @@ -144,6 +146,8 @@ export interface CompressionConfig { stackedPipeline?: CompressionPipelineStep[]; /** Opt-in per-step fidelity gate (default disabled). */ fidelityGate?: FidelityGateConfig; + /** Opt-in risk-gate pre-pass: shields sensitive spans from compression (default disabled). */ + riskGate?: RiskGateConfig; cavemanConfig?: CavemanConfig; cavemanOutputMode?: CavemanOutputModeConfig; /** Phase 4A: selected output styles (supersedes cavemanOutputMode via a back-compat shim). */ @@ -200,6 +204,7 @@ export interface CompressionStats { validationWarnings?: string[]; validationErrors?: string[]; fallbackApplied?: boolean; + riskGate?: RiskGateStats; /** * Phase 4 (B): which `ultra` tier actually ran for this request. * "slm" — Tier-B ran and produced the output. diff --git a/src/app/(dashboard)/dashboard/compression/studio/PlayView.tsx b/src/app/(dashboard)/dashboard/compression/studio/PlayView.tsx index 47dfd23fc5b3..a0f38ba002fe 100644 --- a/src/app/(dashboard)/dashboard/compression/studio/PlayView.tsx +++ b/src/app/(dashboard)/dashboard/compression/studio/PlayView.tsx @@ -5,6 +5,7 @@ import { WaterfallInspector } from "./WaterfallInspector"; import { DiffPane } from "./DiffPane"; import { EncoderComparisonTable } from "./EncoderComparisonTable"; import { PlaygroundInput, LANE_ENGINES } from "./PlaygroundInput"; +import { RiskGateBadge } from "./RiskGateBadge"; export interface PlayViewProps { text: string; onText: (t: string) => void; @@ -45,6 +46,7 @@ export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewP const [fuzzyDedup, setFuzzyDedup] = useState(false); const [selectedLane, setSelectedLane] = useState(null); const [fidelityGate, setFidelityGate] = useState(false); + const [riskGate, setRiskGate] = useState(false); const { batch, loading, run } = usePreviewCompression(); const messages = [{ role: "user", content: text }]; const toggle = (e: string) => @@ -56,6 +58,7 @@ export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewP activeEngines: orderByStack(active, laneEngines), fidelityGate, fuzzyDedup, + riskGate, }); const activeDiff = resolveActiveDiff(batch, selectedLane); return ( @@ -72,6 +75,8 @@ export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewP onToggleFidelity={() => setFidelityGate((v) => !v)} fuzzyDedup={fuzzyDedup} onToggleFuzzy={() => setFuzzyDedup((v) => !v)} + riskGate={riskGate} + onToggleRisk={() => setRiskGate((v) => !v)} />
@@ -81,6 +86,7 @@ export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewP Fluxo combinado — {active.join(" → ")} + )}
diff --git a/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx b/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx index e1e894824fc2..c2ed9ac0bc69 100644 --- a/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx +++ b/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx @@ -1,7 +1,7 @@ "use client"; export const LANE_ENGINES = ["session-dedup", "ccr", "lite", "rtk", "ionizer", "headroom", "caveman", "aggressive", "ultra"] as const; -export interface PlaygroundInputProps { text: string; onText: (t: string) => void; active: string[]; onToggleActive: (engine: string) => void; onRun: () => void; loading: boolean; fidelityGate: boolean; onToggleFidelity: () => void; fuzzyDedup: boolean; onToggleFuzzy: () => void; } -export function PlaygroundInput({ text, onText, active, onToggleActive, onRun, loading, fidelityGate, onToggleFidelity, fuzzyDedup, onToggleFuzzy }: PlaygroundInputProps) { +export interface PlaygroundInputProps { text: string; onText: (t: string) => void; active: string[]; onToggleActive: (engine: string) => void; onRun: () => void; loading: boolean; fidelityGate: boolean; onToggleFidelity: () => void; fuzzyDedup: boolean; onToggleFuzzy: () => void; riskGate: boolean; onToggleRisk: () => void; } +export function PlaygroundInput({ text, onText, active, onToggleActive, onRun, loading, fidelityGate, onToggleFidelity, fuzzyDedup, onToggleFuzzy, riskGate, onToggleRisk }: PlaygroundInputProps) { return (