From 8d50c9de16584693bbe79ec5d16d1d44bafb9777 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 23 Jun 2026 18:31:24 -0300 Subject: [PATCH 001/120] chore(release): open v3.8.36 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/openapi.yaml | 2 +- electron/package-lock.json | 4 ++-- electron/package.json | 2 +- open-sse/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 48 files changed, 261 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b4175f35465..1d463b37a171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 3fb1aaae7cfc..bd754b416c64 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index befb8a4bddc7..239e83ae7e8c 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index befb8a4bddc7..239e83ae7e8c 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 4f51520a9688..21dc18f17a2f 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index 4b1318a093d6..e2ff9646b1d1 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index fb1402cf2a19..0c606b4248c0 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 5282b4d98098..fa14b1ea637e 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 09c3fcd90ff9..23a6702c201a 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 5e6d80520949..326d59934ede 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index c834444ed43a..95bf7f02eaae 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 89e5fffecb0a..11c7dbdaa445 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index bf599d7ca2fc..f88212af5c33 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index 2ababd3676b5..f2cd9fd7dbe2 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 0994d0bc6db7..d097dd1bb6a0 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 24a4e835aef9..ab13522c8974 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index aeff9316ab92..edfeb9d341ef 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index e69e52b78110..fd0554edaf7a 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index bb5efc0ede17..069ae344e5fc 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 1d899e787d7c..89ef9bc3ff01 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index edb856a7590e..4a7094a7febf 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 6aadafcb8bf3..b13815e0384f 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 714553f8b665..c8a56eb1e3e6 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index c4009f11c9fa..1f3e48aa013a 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index d10ce083b820..178f17d7ab1c 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index 659de62030c2..32794ddf161c 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index cd2a237e0d1f..3b4cc8afcde3 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 867ad9df5e3c..c2b123ab663e 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index f6389d6d11c4..d9255640db3b 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index 2cf563eaa150..6a4542c79672 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index a472d9f1b654..50d8ed5b3ffc 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index e880533c5635..a8174c5fba1e 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index a4bbbad3cb39..320030abc4b2 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index ad39acd71a08..77aff3b29b86 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 88ec475e41e2..9aa927320246 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 967431a1fb21..23e6ac6c022e 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index e6db8ab2b982..18e89d51e687 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index 246f00f4cf02..f5e06f029516 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index a5982c420349..3428357e0598 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index c18abff9a639..18ca5afca6e3 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 93f997a4361a..12ef4972650d 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index d6b101062dcf..4cab4ee00d75 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.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ca00d2362355..94c386543711 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.35 + version: 3.8.36 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 fd59e1187406..54c681a2c2c2 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.35", + "version": "3.8.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.35", + "version": "3.8.36", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/electron/package.json b/electron/package.json index c66f0d6b5149..f7eaf45c7e2a 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.35", + "version": "3.8.36", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/package.json b/open-sse/package.json index 109198faad8b..3c06fd0eca31 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.35", + "version": "3.8.36", "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 dd45784f6577..4769f03de36f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.35", + "version": "3.8.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.35", + "version": "3.8.36", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -28502,7 +28502,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.35" + "version": "3.8.36" } } } diff --git a/package.json b/package.json index 9d7e41682aa9..1517eabfa5bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.35", + "version": "3.8.36", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { From e494afebcf6d8a4c05cc762f0363d0ef433287a4 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:00:34 -0300 Subject: [PATCH 002/120] refactor(chatCore): extrai resolveCompressionSettings (#3501) (#4826) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 1/13) --- open-sse/handlers/chatCore.ts | 19 +++----- .../handlers/chatCore/compressionSettings.ts | 35 +++++++++++++++ .../chatcore-compression-settings.test.ts | 44 +++++++++++++++++++ 3 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 open-sse/handlers/chatCore/compressionSettings.ts create mode 100644 tests/unit/chatcore-compression-settings.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 2344be85c5f2..50863e456bd0 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -165,6 +165,7 @@ import { restoreClaudePassthroughToolNames, mergeResponseToolNameMap, } from "./chatCore/passthroughToolNames.ts"; +import { resolveCompressionSettings } from "./chatCore/compressionSettings.ts"; import { recordContextEditingTelemetryHook } from "./chatCore/contextEditingTelemetry.ts"; import { recordCompressionCacheStats } from "./chatCore/compressionCacheStats.ts"; import { writeCavemanOutputAnalytics } from "./chatCore/cavemanOutputAnalytics.ts"; @@ -875,20 +876,10 @@ export async function handleChatCore({ let contextEditingEnabled = false; if (body && Array.isArray(allMessages) && allMessages.length > 0) { let estimatedTokens = estimateTokens(allMessages); - let promptCompressionEnabled = false; - let compressionSettings: CompressionConfig | null = null; - - try { - const { getCompressionSettings } = await import("../../src/lib/db/compression.ts"); - compressionSettings = await getCompressionSettings(); - promptCompressionEnabled = compressionSettings.enabled; - contextEditingEnabled = compressionSettings.contextEditing?.enabled === true; - } catch (err) { - log?.warn?.( - "COMPRESSION", - "Compression settings lookup skipped: " + (err instanceof Error ? err.message : String(err)) - ); - } + const compressionSettingsResult = await resolveCompressionSettings(log); + const compressionSettings: CompressionConfig | null = compressionSettingsResult.settings; + const promptCompressionEnabled = compressionSettingsResult.enabled; + contextEditingEnabled = compressionSettingsResult.contextEditingEnabled; // --- Modular Compression Pipeline (Phase 1 Lite + Phase 2 Standard/Caveman + Phase 3 Aggressive) --- // Runs BEFORE the existing reactive compressContext() to proactively reduce tokens. diff --git a/open-sse/handlers/chatCore/compressionSettings.ts b/open-sse/handlers/chatCore/compressionSettings.ts new file mode 100644 index 000000000000..5642fb50283e --- /dev/null +++ b/open-sse/handlers/chatCore/compressionSettings.ts @@ -0,0 +1,35 @@ +/** + * chatCore compression settings resolution (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's Proactive Context Compression setup: read the canonical + * compression settings and derive the prompt-compression and delegated context-editing flags. + * Best-effort — on a lookup error it logs and falls back to disabled, exactly like the previous + * inline try/catch. Behaviour is byte-identical. + */ + +import type { CompressionConfig } from "../../services/compression/types.ts"; + +type LoggerLike = { warn?: (...args: unknown[]) => void } | null | undefined; + +export async function resolveCompressionSettings(log?: LoggerLike): Promise<{ + settings: CompressionConfig | null; + enabled: boolean; + contextEditingEnabled: boolean; +}> { + try { + const { getCompressionSettings } = await import("@/lib/db/compression"); + const settings = await getCompressionSettings(); + return { + settings, + enabled: settings.enabled, + contextEditingEnabled: settings.contextEditing?.enabled === true, + }; + } catch (err) { + log?.warn?.( + "COMPRESSION", + "Compression settings lookup skipped: " + (err instanceof Error ? err.message : String(err)) + ); + return { settings: null, enabled: false, contextEditingEnabled: false }; + } +} diff --git a/tests/unit/chatcore-compression-settings.test.ts b/tests/unit/chatcore-compression-settings.test.ts new file mode 100644 index 000000000000..8ec9af0bd17f --- /dev/null +++ b/tests/unit/chatcore-compression-settings.test.ts @@ -0,0 +1,44 @@ +// Characterization of resolveCompressionSettings — the compression settings read extracted from +// handleChatCore's Proactive Context Compression setup (chatCore god-file decomposition, #3501). +// Uses a real temp DB (getCompressionSettings reads/seeds the settings row). Locks: the derived +// enabled / contextEditingEnabled flags and the disabled fallback shape. +import { test, before, after } 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 testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-comp-settings-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { resolveCompressionSettings } = await import( + "../../open-sse/handlers/chatCore/compressionSettings.ts" +); + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + try { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +test("returns the settings object from the DB", async () => { + const result = await resolveCompressionSettings(); + assert.ok(result.settings, "expected a settings object from the seeded DB"); +}); + +test("derives enabled and contextEditingEnabled from the settings", async () => { + const result = await resolveCompressionSettings(); + // `enabled` mirrors settings.enabled exactly + assert.equal(result.enabled, result.settings!.enabled); + // `contextEditingEnabled` is the strict === true derivation of the nested flag + assert.equal(result.contextEditingEnabled, result.settings!.contextEditing?.enabled === true); + assert.equal(typeof result.contextEditingEnabled, "boolean"); +}); From e420c34fce02b2bc3cf7682a52c68484260d717c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:01:09 -0300 Subject: [PATCH 003/120] =?UTF-8?q?refactor(chatCore):=20extrai=20predicad?= =?UTF-8?q?os=20puros=20de=20combo=20de=20compress=C3=A3o=20(#3501)=20(#48?= =?UTF-8?q?24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 (#3501 chatCore extraction stack 2/13) --- open-sse/handlers/chatCore.ts | 34 ++-------- .../chatCore/compressionComboPredicates.ts | 41 ++++++++++++ ...tcore-compression-combo-predicates.test.ts | 64 +++++++++++++++++++ 3 files changed, 110 insertions(+), 29 deletions(-) create mode 100644 open-sse/handlers/chatCore/compressionComboPredicates.ts create mode 100644 tests/unit/chatcore-compression-combo-predicates.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 50863e456bd0..ae82310eb8d3 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -166,6 +166,11 @@ import { mergeResponseToolNameMap, } from "./chatCore/passthroughToolNames.ts"; import { resolveCompressionSettings } from "./chatCore/compressionSettings.ts"; +import { + isBuiltinStackedPipeline, + isStackedCompressionCombo, + type RuntimeCompressionCombo, +} from "./chatCore/compressionComboPredicates.ts"; import { recordContextEditingTelemetryHook } from "./chatCore/contextEditingTelemetry.ts"; import { recordCompressionCacheStats } from "./chatCore/compressionCacheStats.ts"; import { writeCavemanOutputAnalytics } from "./chatCore/cavemanOutputAnalytics.ts"; @@ -908,27 +913,6 @@ export async function handleChatCore({ } let compressionComboKey = comboName ?? null; let compressionComboApplied = false; - type RuntimeCompressionCombo = { - id: string; - pipeline: NonNullable; - languagePacks: string[]; - outputMode: boolean; - outputModeIntensity: string; - }; - const isBuiltinStackedPipeline = ( - pipeline: CompressionConfig["stackedPipeline"] | undefined - ): boolean => { - if (!Array.isArray(pipeline) || pipeline.length !== 2) return false; - const [first, second] = pipeline; - return ( - first?.engine === "rtk" && - (first.intensity === undefined || first.intensity === "standard") && - !first.config && - second?.engine === "caveman" && - (second.intensity === undefined || second.intensity === "full") && - !second.config - ); - }; const applyCompressionComboConfig = ( compressionCombo: RuntimeCompressionCombo | null, routingOverrideIds: string[] = [] @@ -987,14 +971,6 @@ export async function handleChatCore({ compressionComboApplied = true; return true; }; - const isStackedCompressionCombo = ( - compressionCombo: RuntimeCompressionCombo | null - ): compressionCombo is RuntimeCompressionCombo => { - // >= 1: a single-engine default combo (user enabled exactly one layer via the - // per-engine config page) must still apply. applyCompressionComboConfig already - // guards length === 0. - return Boolean(compressionCombo && compressionCombo.pipeline.length >= 1); - }; if (isCombo && comboName) { try { const { getComboByName } = await import("../../src/lib/localDb"); diff --git a/open-sse/handlers/chatCore/compressionComboPredicates.ts b/open-sse/handlers/chatCore/compressionComboPredicates.ts new file mode 100644 index 000000000000..d73b4324cb72 --- /dev/null +++ b/open-sse/handlers/chatCore/compressionComboPredicates.ts @@ -0,0 +1,41 @@ +/** + * chatCore compression-combo predicates (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Pure predicates extracted from handleChatCore's compression setup: detect the built-in + * RTK→caveman stacked pipeline and whether a runtime combo has at least one pipeline layer. + * No handler state is captured; behaviour is byte-identical to the previous inline closures. + */ + +import type { CompressionConfig } from "../../services/compression/types.ts"; + +export type RuntimeCompressionCombo = { + id: string; + pipeline: NonNullable; + languagePacks: string[]; + outputMode: boolean; + outputModeIntensity: string; +}; + +export function isBuiltinStackedPipeline( + pipeline: CompressionConfig["stackedPipeline"] | undefined +): boolean { + if (!Array.isArray(pipeline) || pipeline.length !== 2) return false; + const [first, second] = pipeline; + return ( + first?.engine === "rtk" && + (first.intensity === undefined || first.intensity === "standard") && + !first.config && + second?.engine === "caveman" && + (second.intensity === undefined || second.intensity === "full") && + !second.config + ); +} + +export function isStackedCompressionCombo( + compressionCombo: RuntimeCompressionCombo | null +): compressionCombo is RuntimeCompressionCombo { + // >= 1: a single-engine default combo (user enabled exactly one layer via the per-engine config + // page) must still apply. applyCompressionComboConfig already guards length === 0. + return Boolean(compressionCombo && compressionCombo.pipeline.length >= 1); +} diff --git a/tests/unit/chatcore-compression-combo-predicates.test.ts b/tests/unit/chatcore-compression-combo-predicates.test.ts new file mode 100644 index 000000000000..34fb88761db0 --- /dev/null +++ b/tests/unit/chatcore-compression-combo-predicates.test.ts @@ -0,0 +1,64 @@ +// Characterization of the pure compression-combo predicates extracted from handleChatCore's +// compression setup (chatCore god-file decomposition, #3501). No DB, no handler state. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { isBuiltinStackedPipeline, isStackedCompressionCombo } = await import( + "../../open-sse/handlers/chatCore/compressionComboPredicates.ts" +); + +test("isBuiltinStackedPipeline true only for the rtk(standard)→caveman(full) shape", () => { + assert.equal( + isBuiltinStackedPipeline([{ engine: "rtk" }, { engine: "caveman" }] as never), + true + ); + assert.equal( + isBuiltinStackedPipeline([ + { engine: "rtk", intensity: "standard" }, + { engine: "caveman", intensity: "full" }, + ] as never), + true + ); +}); + +test("isBuiltinStackedPipeline false for wrong length / engines / intensities / config", () => { + assert.equal(isBuiltinStackedPipeline(undefined), false); + assert.equal(isBuiltinStackedPipeline([] as never), false); + assert.equal(isBuiltinStackedPipeline([{ engine: "rtk" }] as never), false); + assert.equal( + isBuiltinStackedPipeline([{ engine: "caveman" }, { engine: "rtk" }] as never), + false + ); + assert.equal( + isBuiltinStackedPipeline([ + { engine: "rtk", intensity: "aggressive" }, + { engine: "caveman", intensity: "full" }, + ] as never), + false + ); + assert.equal( + isBuiltinStackedPipeline([ + { engine: "rtk", config: { x: 1 } }, + { engine: "caveman" }, + ] as never), + false + ); +}); + +test("isStackedCompressionCombo true when the combo has >= 1 pipeline layer", () => { + assert.equal(isStackedCompressionCombo(null), false); + assert.equal( + isStackedCompressionCombo({ id: "c", pipeline: [], languagePacks: [], outputMode: false, outputModeIntensity: "full" } as never), + false + ); + assert.equal( + isStackedCompressionCombo({ + id: "c", + pipeline: [{ engine: "rtk" }], + languagePacks: [], + outputMode: false, + outputModeIntensity: "full", + } as never), + true + ); +}); From 34b510a5439f1852113bc79308c0570e573e092a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:01:20 -0300 Subject: [PATCH 004/120] refactor(chatCore): extrai emitOutputStyleTelemetry (#3501) (#4811) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 3/13) --- open-sse/handlers/chatCore.ts | 38 ++----- .../handlers/chatCore/outputStyleTelemetry.ts | 53 +++++++++ .../chatcore-output-style-telemetry.test.ts | 102 ++++++++++++++++++ 3 files changed, 166 insertions(+), 27 deletions(-) create mode 100644 open-sse/handlers/chatCore/outputStyleTelemetry.ts create mode 100644 tests/unit/chatcore-output-style-telemetry.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index ae82310eb8d3..743e18816047 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -171,6 +171,7 @@ import { isStackedCompressionCombo, type RuntimeCompressionCombo, } from "./chatCore/compressionComboPredicates.ts"; +import { emitOutputStyleTelemetry } from "./chatCore/outputStyleTelemetry.ts"; import { recordContextEditingTelemetryHook } from "./chatCore/contextEditingTelemetry.ts"; import { recordCompressionCacheStats } from "./chatCore/compressionCacheStats.ts"; import { writeCavemanOutputAnalytics } from "./chatCore/cavemanOutputAnalytics.ts"; @@ -1374,33 +1375,16 @@ export async function handleChatCore({ log, }); } - if (outputStyleResult) { - void (async () => { - try { - const { buildOutputStyleTelemetry } = - await import("../services/compression/outputStyles/telemetry.ts"); - const { insertCompressionRunTelemetryRow } = - await import("../../src/lib/db/compressionRunTelemetry.ts"); - const record = buildOutputStyleTelemetry({ - requestId: skillRequestId ?? traceId ?? "", - model: effectiveModel ?? "", - provider: provider ?? "", - source: config.compressionComboId ? "active-profile" : "default", - tokensBefore: estimatedTokens, - tokensAfter: estimatedTokens, - applied: outputStyleResult.applied, - appliedStyles: outputStyleResult.appliedStyles, - skippedReason: outputStyleResult.skippedReason, - }); - insertCompressionRunTelemetryRow(record); - } catch (err) { - log?.debug?.( - "COMPRESSION", - "Run-telemetry emit skipped: " + (err instanceof Error ? err.message : String(err)) - ); - } - })(); - } + emitOutputStyleTelemetry({ + outputStyleResult, + skillRequestId, + traceId, + effectiveModel, + provider, + compressionComboId: config.compressionComboId, + estimatedTokens, + log, + }); } catch (err) { log?.warn?.( "COMPRESSION", diff --git a/open-sse/handlers/chatCore/outputStyleTelemetry.ts b/open-sse/handlers/chatCore/outputStyleTelemetry.ts new file mode 100644 index 000000000000..4bfa2e115866 --- /dev/null +++ b/open-sse/handlers/chatCore/outputStyleTelemetry.ts @@ -0,0 +1,53 @@ +/** + * chatCore output-style run-telemetry hook (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's request-setup compression path: when the unified output styles + * ran, emit the per-run telemetry record so output-style usage surfaces in compression analytics. + * Best-effort, fire-and-forget — an un-awaited IIFE that swallows its own errors and never affects + * the request. Behaviour is byte-identical to the previous inline block. + */ + +import { type OutputStylesResult } from "../../services/compression/outputStyles/apply.ts"; + +type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; + +export function emitOutputStyleTelemetry(args: { + outputStyleResult: OutputStylesResult | null | undefined; + skillRequestId: string | null | undefined; + traceId: string | null | undefined; + effectiveModel: string | null | undefined; + provider: string | null | undefined; + compressionComboId: string | null | undefined; + estimatedTokens: number; + log?: LoggerLike; +}): void { + const result = args.outputStyleResult; + if (!result) return; + + void (async () => { + try { + const { buildOutputStyleTelemetry } = await import( + "../../services/compression/outputStyles/telemetry.ts" + ); + const { insertCompressionRunTelemetryRow } = await import("@/lib/db/compressionRunTelemetry"); + const record = buildOutputStyleTelemetry({ + requestId: args.skillRequestId ?? args.traceId ?? "", + model: args.effectiveModel ?? "", + provider: args.provider ?? "", + source: args.compressionComboId ? "active-profile" : "default", + tokensBefore: args.estimatedTokens, + tokensAfter: args.estimatedTokens, + applied: result.applied, + appliedStyles: result.appliedStyles, + skippedReason: result.skippedReason, + }); + insertCompressionRunTelemetryRow(record); + } catch (err) { + args.log?.debug?.( + "COMPRESSION", + "Run-telemetry emit skipped: " + (err instanceof Error ? err.message : String(err)) + ); + } + })(); +} diff --git a/tests/unit/chatcore-output-style-telemetry.test.ts b/tests/unit/chatcore-output-style-telemetry.test.ts new file mode 100644 index 000000000000..d91b3e09100d --- /dev/null +++ b/tests/unit/chatcore-output-style-telemetry.test.ts @@ -0,0 +1,102 @@ +// Characterization of emitOutputStyleTelemetry — the output-style run-telemetry hook extracted from +// handleChatCore's request-setup compression path (chatCore god-file decomposition, #3501). +// Fire-and-forget; uses a real temp DB and polls compression_run_telemetry. Locks: the null guard +// (no-op), and that an applied output-style result records a run-telemetry row with the resolved +// request id / model / provider / source. +import { test, before, after } 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 testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-os-telemetry-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { emitOutputStyleTelemetry } = await import( + "../../open-sse/handlers/chatCore/outputStyleTelemetry.ts" +); + +function rowFor(requestId: string): Record | undefined { + try { + return coreDb + .getDbInstance() + .prepare( + "SELECT request_id, model, provider, source FROM compression_run_telemetry WHERE request_id = ?" + ) + .get(requestId) as Record | undefined; + } catch { + // table is created lazily by the first fire-and-forget insert; treat "no such table" as "no row" + return undefined; + } +} + +async function waitForRow(requestId: string, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (rowFor(requestId)) break; + await new Promise((r) => setTimeout(r, 25)); + } + return rowFor(requestId); +} + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + try { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +test("null outputStyleResult is a no-op (returns synchronously, no throw)", async () => { + assert.doesNotThrow(() => + emitOutputStyleTelemetry({ + outputStyleResult: null, + skillRequestId: "os-noop", + traceId: "t", + effectiveModel: "gpt-x", + provider: "openai", + compressionComboId: null, + estimatedTokens: 100, + }) + ); + await new Promise((r) => setTimeout(r, 80)); + assert.equal(rowFor("os-noop"), undefined); +}); + +test("applied output-style result records a run-telemetry row (source=active-profile when combo id set)", async () => { + emitOutputStyleTelemetry({ + outputStyleResult: { body: {} as never, applied: true, appliedStyles: [], skippedReason: undefined }, + skillRequestId: "os-req-1", + traceId: "trace-1", + effectiveModel: "gpt-os", + provider: "openai", + compressionComboId: "combo-9", + estimatedTokens: 250, + }); + const row = await waitForRow("os-req-1"); + assert.ok(row, "expected a compression_run_telemetry row"); + assert.equal(row!.model, "gpt-os"); + assert.equal(row!.provider, "openai"); + assert.equal(row!.source, "active-profile"); +}); + +test("falls back to traceId for request id and source=default without a combo id", async () => { + emitOutputStyleTelemetry({ + outputStyleResult: { body: {} as never, applied: true, appliedStyles: [] }, + skillRequestId: null, + traceId: "os-trace-2", + effectiveModel: "gpt-x", + provider: "anthropic", + compressionComboId: null, + estimatedTokens: 10, + }); + const row = await waitForRow("os-trace-2"); + assert.ok(row, "expected the row keyed by the traceId fallback"); + assert.equal(row!.source, "default"); +}); From 812f2f20a1fddfe5e20263b42148e8af1017af00 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:01:30 -0300 Subject: [PATCH 005/120] refactor(chatCore): extrai writeCompressionAnalytics (bloco analytics completo, #3501) (#4817) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 4/13) --- open-sse/handlers/chatCore.ts | 82 ++--------- .../chatCore/compressionAnalyticsWrite.ts | 113 +++++++++++++++ ...atcore-compression-analytics-write.test.ts | 129 ++++++++++++++++++ 3 files changed, 256 insertions(+), 68 deletions(-) create mode 100644 open-sse/handlers/chatCore/compressionAnalyticsWrite.ts create mode 100644 tests/unit/chatcore-compression-analytics-write.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 743e18816047..9a88f3012427 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -172,6 +172,7 @@ import { type RuntimeCompressionCombo, } from "./chatCore/compressionComboPredicates.ts"; import { emitOutputStyleTelemetry } from "./chatCore/outputStyleTelemetry.ts"; +import { writeCompressionAnalytics } from "./chatCore/compressionAnalyticsWrite.ts"; import { recordContextEditingTelemetryHook } from "./chatCore/contextEditingTelemetry.ts"; import { recordCompressionCacheStats } from "./chatCore/compressionCacheStats.ts"; import { writeCavemanOutputAnalytics } from "./chatCore/cavemanOutputAnalytics.ts"; @@ -1277,74 +1278,19 @@ export async function handleChatCore({ if (result.compressed || result.stats.fallbackApplied || cavemanOutputModeApplied) { trackCompressionStats(result.stats); compressionAnalyticsRecorded = true; - compressionAnalyticsWritePromise = (async () => { - try { - const { insertCompressionAnalyticsRow, insertCompressionEngineBreakdown } = - await import("../../src/lib/db/compressionAnalytics.ts"); - const { calculateCost } = await import("../../src/lib/usage/costCalculator.ts"); - const tokensSaved = Math.max( - 0, - result.stats.originalTokens - result.stats.compressedTokens - ); - const rtkPointers = result.stats.rtkRawOutputPointers ?? []; - const estimatedUsdSaved = await calculateCost( - provider ?? "", - effectiveModel ?? "", - { - input: tokensSaved, - }, - { serviceTier: effectiveServiceTier } - ); - insertCompressionAnalyticsRow({ - timestamp: new Date().toISOString(), - combo_id: comboName ?? null, - provider: provider ?? null, - mode, - engine: result.stats.engine ?? mode, - compression_combo_id: - result.stats.compressionComboId ?? config.compressionComboId ?? null, - original_tokens: result.stats.originalTokens, - compressed_tokens: result.stats.compressedTokens, - tokens_saved: tokensSaved, - duration_ms: result.stats.durationMs ?? null, - request_id: skillRequestId, - estimated_usd_saved: estimatedUsdSaved || null, - validation_fallback: result.stats.fallbackApplied ? 1 : 0, - output_mode: cavemanOutputModeApplied ? cavemanOutputModeIntensity : null, - rtk_raw_output_pointer: rtkPointers[0]?.id ?? null, - rtk_raw_output_bytes: rtkPointers[0]?.bytes ?? null, - rtk_raw_output_pointers: rtkPointers.length - ? JSON.stringify(rtkPointers.map((pointer) => pointer.id)) - : null, - rtk_raw_output_total_bytes: rtkPointers.length - ? rtkPointers.reduce((total, pointer) => total + pointer.bytes, 0) - : null, - }); - // Persist the per-engine breakdown of a stacked run so per-engine - // analytics (getPerEngineAnalytics) is accurate historically, not just - // in the live `compression.completed` event. - const engineBreakdown = result.stats.engineBreakdown ?? []; - if (engineBreakdown.length > 0) { - insertCompressionEngineBreakdown( - engineBreakdown.map((b) => ({ - timestamp: new Date().toISOString(), - request_id: skillRequestId, - engine: b.engine, - original_tokens: b.originalTokens, - compressed_tokens: b.compressedTokens, - tokens_saved: Math.max(0, b.originalTokens - b.compressedTokens), - duration_ms: b.durationMs ?? null, - })) - ); - } - } catch (err) { - log?.debug?.( - "COMPRESSION", - "Compression analytics write skipped: " + - (err instanceof Error ? err.message : String(err)) - ); - } - })(); + compressionAnalyticsWritePromise = writeCompressionAnalytics({ + stats: result.stats, + provider, + effectiveModel, + effectiveServiceTier, + comboName, + mode, + compressionComboId: config.compressionComboId, + skillRequestId, + cavemanOutputModeApplied, + cavemanOutputModeIntensity, + log, + }); } if (result.compressed) { diff --git a/open-sse/handlers/chatCore/compressionAnalyticsWrite.ts b/open-sse/handlers/chatCore/compressionAnalyticsWrite.ts new file mode 100644 index 000000000000..40319e3c52c6 --- /dev/null +++ b/open-sse/handlers/chatCore/compressionAnalyticsWrite.ts @@ -0,0 +1,113 @@ +/** + * chatCore compression analytics write (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's request-setup compression path: persist the per-run compression + * analytics row (cost saved, RTK raw-output pointers) plus the per-engine breakdown of a stacked + * run. Returns the write promise (the caller assigns it to compressionAnalyticsWritePromise) and + * swallows its own errors — best-effort, off the hot path, never throws into a request. Behaviour + * is byte-identical to the previous inline block. Split into small builders so each stays under the + * complexity cap. + */ + +import { type CompressionStats } from "../../services/compression/stats.ts"; + +type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; + +type WriteOpts = { + stats: CompressionStats; + provider: string | null | undefined; + effectiveModel: string | null | undefined; + effectiveServiceTier: string | undefined; + comboName: string | null | undefined; + mode: string; + compressionComboId: string | null | undefined; + skillRequestId: string; + cavemanOutputModeApplied: boolean; + cavemanOutputModeIntensity: string | null | undefined; + log?: LoggerLike; +}; + +type RtkPointer = { id?: string | null; bytes?: number | null }; + +function buildRtkPointerFields(rtkPointers: RtkPointer[]) { + return { + rtk_raw_output_pointer: rtkPointers[0]?.id ?? null, + rtk_raw_output_bytes: rtkPointers[0]?.bytes ?? null, + rtk_raw_output_pointers: rtkPointers.length + ? JSON.stringify(rtkPointers.map((pointer) => pointer.id)) + : null, + rtk_raw_output_total_bytes: rtkPointers.length + ? rtkPointers.reduce((total, pointer) => total + (pointer.bytes ?? 0), 0) + : null, + }; +} + +function buildAnalyticsRow( + opts: WriteOpts, + tokensSaved: number, + rtkPointers: RtkPointer[], + estimatedUsdSaved: number +) { + const { stats } = opts; + return { + timestamp: new Date().toISOString(), + combo_id: opts.comboName ?? null, + provider: opts.provider ?? null, + mode: opts.mode, + engine: stats.engine ?? opts.mode, + compression_combo_id: stats.compressionComboId ?? opts.compressionComboId ?? null, + original_tokens: stats.originalTokens, + compressed_tokens: stats.compressedTokens, + tokens_saved: tokensSaved, + duration_ms: stats.durationMs ?? null, + request_id: opts.skillRequestId, + estimated_usd_saved: estimatedUsdSaved || null, + validation_fallback: stats.fallbackApplied ? 1 : 0, + output_mode: opts.cavemanOutputModeApplied ? opts.cavemanOutputModeIntensity : null, + ...buildRtkPointerFields(rtkPointers), + }; +} + +function buildEngineBreakdownRows(stats: CompressionStats, requestId: string) { + const engineBreakdown = stats.engineBreakdown ?? []; + return engineBreakdown.map((b) => ({ + timestamp: new Date().toISOString(), + request_id: requestId, + engine: b.engine, + original_tokens: b.originalTokens, + compressed_tokens: b.compressedTokens, + tokens_saved: Math.max(0, b.originalTokens - b.compressedTokens), + duration_ms: b.durationMs ?? null, + })); +} + +export function writeCompressionAnalytics(opts: WriteOpts): Promise { + return (async () => { + try { + const { insertCompressionAnalyticsRow, insertCompressionEngineBreakdown } = await import( + "@/lib/db/compressionAnalytics" + ); + const { calculateCost } = await import("@/lib/usage/costCalculator"); + const { stats } = opts; + const tokensSaved = Math.max(0, stats.originalTokens - stats.compressedTokens); + const rtkPointers = (stats.rtkRawOutputPointers ?? []) as RtkPointer[]; + const estimatedUsdSaved = await calculateCost( + opts.provider ?? "", + opts.effectiveModel ?? "", + { input: tokensSaved }, + { serviceTier: opts.effectiveServiceTier } + ); + insertCompressionAnalyticsRow(buildAnalyticsRow(opts, tokensSaved, rtkPointers, estimatedUsdSaved)); + const breakdownRows = buildEngineBreakdownRows(stats, opts.skillRequestId); + if (breakdownRows.length > 0) { + insertCompressionEngineBreakdown(breakdownRows); + } + } catch (err) { + opts.log?.debug?.( + "COMPRESSION", + "Compression analytics write skipped: " + (err instanceof Error ? err.message : String(err)) + ); + } + })(); +} diff --git a/tests/unit/chatcore-compression-analytics-write.test.ts b/tests/unit/chatcore-compression-analytics-write.test.ts new file mode 100644 index 000000000000..15768b22e17b --- /dev/null +++ b/tests/unit/chatcore-compression-analytics-write.test.ts @@ -0,0 +1,129 @@ +// Characterization of writeCompressionAnalytics — the full compression analytics write extracted +// from handleChatCore's request-setup compression path (chatCore god-file decomposition, #3501). +// Returns the write promise; uses a real temp DB. Locks: the analytics row mapping (tokens, engine, +// validation_fallback, output_mode), the per-engine breakdown insert, and fail-open. +import { test, before, after } 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 testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-comp-analytics-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { writeCompressionAnalytics } = await import( + "../../open-sse/handlers/chatCore/compressionAnalyticsWrite.ts" +); + +type Stats = Parameters[0]["stats"]; + +function makeStats(overrides: Record = {}): Stats { + return { + originalTokens: 200, + compressedTokens: 80, + fallbackApplied: false, + engine: "rtk", + compressionComboId: null, + durationMs: 12, + rtkRawOutputPointers: [], + engineBreakdown: [], + ...overrides, + } as unknown as Stats; +} + +function analyticsRow(requestId: string): Record | undefined { + try { + return coreDb + .getDbInstance() + .prepare( + "SELECT mode, engine, original_tokens AS orig, compressed_tokens AS comp, tokens_saved AS saved, validation_fallback AS vf FROM compression_analytics WHERE request_id = ?" + ) + .get(requestId) as Record | undefined; + } catch { + return undefined; + } +} + +function breakdownCount(requestId: string): number { + try { + const row = coreDb + .getDbInstance() + .prepare("SELECT COUNT(*) AS n FROM compression_engine_breakdown WHERE request_id = ?") + .get(requestId) as { n: number } | undefined; + return row?.n ?? 0; + } catch { + return 0; + } +} + +async function waitFor(pred: () => boolean, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && !pred()) { + await new Promise((r) => setTimeout(r, 25)); + } +} + +function baseOpts(requestId: string, statsOverrides = {}) { + return { + stats: makeStats(statsOverrides), + provider: "openai", + effectiveModel: "gpt-x", + effectiveServiceTier: "standard", + comboName: null, + mode: "rtk", + compressionComboId: null, + skillRequestId: requestId, + cavemanOutputModeApplied: false, + cavemanOutputModeIntensity: null, + } as Parameters[0]; +} + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + try { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +test("writes the analytics row with mapped tokens/engine and resolves", async () => { + await writeCompressionAnalytics(baseOpts("ca-req-1")); + const row = analyticsRow("ca-req-1"); + assert.ok(row, "expected a compression_analytics row"); + assert.equal(row!.engine, "rtk"); + assert.equal(row!.orig, 200); + assert.equal(row!.comp, 80); + assert.equal(row!.saved, 120); + assert.equal(row!.vf, 0); +}); + +test("validation_fallback is 1 when the stats flagged a fallback", async () => { + await writeCompressionAnalytics(baseOpts("ca-req-2", { fallbackApplied: true })); + const row = analyticsRow("ca-req-2"); + assert.equal(row!.vf, 1); +}); + +test("inserts a per-engine breakdown when present", async () => { + await writeCompressionAnalytics( + baseOpts("ca-req-3", { + engineBreakdown: [ + { engine: "rtk", originalTokens: 200, compressedTokens: 120, durationMs: 5 }, + { engine: "caveman", originalTokens: 120, compressedTokens: 80, durationMs: 4 }, + ], + }) + ); + await waitFor(() => breakdownCount("ca-req-3") >= 2); + assert.equal(breakdownCount("ca-req-3"), 2); +}); + +test("never rejects even on a bad write (fail-open)", async () => { + coreDb.resetDbInstance(); + await assert.doesNotReject(writeCompressionAnalytics(baseOpts("ca-req-4"))); + await coreDb.ensureDbInitialized(); +}); From eb1920bf918decd8e49939cf51021e644b2705a1 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:01:40 -0300 Subject: [PATCH 006/120] refactor(chatCore): extrai runPluginOnRequestHook (#3501) (#4827) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 5/13) --- open-sse/handlers/chatCore.ts | 61 +++++----------- open-sse/handlers/chatCore/pluginOnRequest.ts | 65 +++++++++++++++++ tests/unit/chatcore-plugin-onrequest.test.ts | 73 +++++++++++++++++++ 3 files changed, 156 insertions(+), 43 deletions(-) create mode 100644 open-sse/handlers/chatCore/pluginOnRequest.ts create mode 100644 tests/unit/chatcore-plugin-onrequest.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 9a88f3012427..8331cd578022 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -173,6 +173,7 @@ import { } from "./chatCore/compressionComboPredicates.ts"; import { emitOutputStyleTelemetry } from "./chatCore/outputStyleTelemetry.ts"; import { writeCompressionAnalytics } from "./chatCore/compressionAnalyticsWrite.ts"; +import { runPluginOnRequestHook } from "./chatCore/pluginOnRequest.ts"; import { recordContextEditingTelemetryHook } from "./chatCore/contextEditingTelemetry.ts"; import { recordCompressionCacheStats } from "./chatCore/compressionCacheStats.ts"; import { writeCavemanOutputAnalytics } from "./chatCore/cavemanOutputAnalytics.ts"; @@ -385,50 +386,24 @@ export async function handleChatCore({ body = injectSystemPrompt(body); // ── Plugin onRequest hook ── // Dynamic import cached by Node.js after first call — minimal overhead - try { - const { runOnRequest } = await import("@/lib/plugins/hooks"); - const pluginCtx = { - requestId: traceId, - body, - model, - provider, - apiKeyInfo, - metadata: {}, + const pluginGate = await runPluginOnRequestHook({ + requestId: traceId, + body, + model, + provider, + apiKeyInfo, + log, + }); + if (pluginGate.blocked) { + return { + success: false, + status: 403, + error: "Request blocked by plugin", + response: pluginGate.response, }; - const pluginResult = await runOnRequest(pluginCtx); - if (pluginResult?.blocked) { - log?.info?.("PLUGIN", `Request blocked by plugin`); - return { - success: false, - status: 403, - error: "Request blocked by plugin", - response: pluginResult.response - ? new Response(JSON.stringify(pluginResult.response), { - status: 403, - headers: { "Content-Type": "application/json" }, - }) - : new Response( - JSON.stringify({ - error: { message: "Request blocked by plugin", type: "plugin_block" }, - }), - { - status: 403, - headers: { "Content-Type": "application/json" }, - } - ), - }; - } - if (pluginResult?.body) { - body = pluginResult.body; - } - if (pluginResult?.metadata) { - Object.assign(pluginCtx.metadata, pluginResult.metadata); - } - } catch (pluginErr) { - log?.debug?.( - "PLUGIN", - `onRequest hook error (non-fatal): ${pluginErr instanceof Error ? pluginErr.message : String(pluginErr)}` - ); + } + if (pluginGate.body) { + body = pluginGate.body; } let effectiveServiceTier: EffectiveServiceTier = "standard"; diff --git a/open-sse/handlers/chatCore/pluginOnRequest.ts b/open-sse/handlers/chatCore/pluginOnRequest.ts new file mode 100644 index 000000000000..170c276c5e9a --- /dev/null +++ b/open-sse/handlers/chatCore/pluginOnRequest.ts @@ -0,0 +1,65 @@ +/** + * chatCore plugin onRequest hook (Quality Gate v2 / Fase 9 — chatCore god-file decomposition, + * #3501). + * + * Extracted from handleChatCore's request entry: run the registered plugin `onRequest` hooks. The + * hook may block the request (→ the handler returns a 403), rewrite the body (→ the handler + * reassigns `body`), or do nothing. Fail-open — a misbehaving plugin is logged and ignored. Returns + * a discriminated result so the early-return + body reassignment stay in the handler; behaviour is + * byte-identical to the previous inline block. + */ + +type LoggerLike = + | { info?: (...args: unknown[]) => void; debug?: (...args: unknown[]) => void } + | null + | undefined; + +export type PluginOnRequestGate = + | { blocked: true; response: Response } + | { blocked: false; body?: unknown }; + +const JSON_HEADERS = { status: 403, headers: { "Content-Type": "application/json" } } as const; + +export async function runPluginOnRequestHook(args: { + requestId: string; + body: unknown; + model: string | null | undefined; + provider: string | null | undefined; + apiKeyInfo: unknown; + log?: LoggerLike; +}): Promise { + try { + const { runOnRequest } = await import("@/lib/plugins/hooks"); + const pluginCtx = { + requestId: args.requestId, + body: args.body, + model: args.model, + provider: args.provider, + apiKeyInfo: args.apiKeyInfo, + metadata: {}, + }; + const pluginResult = await runOnRequest(pluginCtx); + if (pluginResult?.blocked) { + args.log?.info?.("PLUGIN", `Request blocked by plugin`); + const response = pluginResult.response + ? new Response(JSON.stringify(pluginResult.response), JSON_HEADERS) + : new Response( + JSON.stringify({ + error: { message: "Request blocked by plugin", type: "plugin_block" }, + }), + JSON_HEADERS + ); + return { blocked: true, response }; + } + if (pluginResult?.metadata) { + Object.assign(pluginCtx.metadata, pluginResult.metadata); + } + return { blocked: false, body: pluginResult?.body }; + } catch (pluginErr) { + args.log?.debug?.( + "PLUGIN", + `onRequest hook error (non-fatal): ${pluginErr instanceof Error ? pluginErr.message : String(pluginErr)}` + ); + return { blocked: false }; + } +} diff --git a/tests/unit/chatcore-plugin-onrequest.test.ts b/tests/unit/chatcore-plugin-onrequest.test.ts new file mode 100644 index 000000000000..ed343e006ac6 --- /dev/null +++ b/tests/unit/chatcore-plugin-onrequest.test.ts @@ -0,0 +1,73 @@ +// Characterization of runPluginOnRequestHook — the plugin onRequest gate extracted from +// handleChatCore's request entry (chatCore god-file decomposition, #3501). Hooks are in-memory. +// Locks: the discriminated result — blocked (403 Response) vs body-rewrite vs pass-through — and +// fail-open on a throwing hook. +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +const { registerHook, unregisterHook } = await import("../../src/lib/plugins/hooks.ts"); +const { runPluginOnRequestHook } = await import( + "../../open-sse/handlers/chatCore/pluginOnRequest.ts" +); + +const PLUGIN = "test-onrequest-plugin"; + +afterEach(() => { + unregisterHook("onRequest", PLUGIN); +}); + +function baseArgs(overrides: Record = {}) { + return { + requestId: "req-1", + body: { messages: [{ role: "user", content: "hi" }] }, + model: "gpt-x", + provider: "openai", + apiKeyInfo: null, + ...overrides, + } as Parameters[0]; +} + +test("no registered hooks → pass-through (blocked:false, no body)", async () => { + const gate = await runPluginOnRequestHook(baseArgs()); + assert.equal(gate.blocked, false); +}); + +test("a blocking hook → blocked:true with a 403 JSON Response", async () => { + registerHook("onRequest", PLUGIN, async () => ({ + blocked: true, + response: { error: "nope" }, + })); + const gate = await runPluginOnRequestHook(baseArgs()); + assert.equal(gate.blocked, true); + if (!gate.blocked) return; + assert.equal(gate.response.status, 403); + const payload = await gate.response.json(); + assert.deepEqual(payload, { error: "nope" }); +}); + +test("a blocking hook without a response → generic plugin_block 403", async () => { + registerHook("onRequest", PLUGIN, async () => ({ blocked: true })); + const gate = await runPluginOnRequestHook(baseArgs()); + assert.equal(gate.blocked, true); + if (!gate.blocked) return; + assert.equal(gate.response.status, 403); + const payload = (await gate.response.json()) as { error?: { type?: string } }; + assert.equal(payload.error?.type, "plugin_block"); +}); + +test("a body-rewriting hook → blocked:false with the new body", async () => { + const rewritten = { messages: [{ role: "user", content: "rewritten" }] }; + registerHook("onRequest", PLUGIN, async () => ({ body: rewritten })); + const gate = await runPluginOnRequestHook(baseArgs()); + assert.equal(gate.blocked, false); + if (gate.blocked) return; + assert.deepEqual(gate.body, rewritten); +}); + +test("a throwing hook → fail-open pass-through (blocked:false)", async () => { + registerHook("onRequest", PLUGIN, async () => { + throw new Error("boom"); + }); + const gate = await runPluginOnRequestHook(baseArgs()); + assert.equal(gate.blocked, false); +}); From e85fb9bb5d9fdb870addada0540ab0e5f2934410 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:01:50 -0300 Subject: [PATCH 007/120] refactor(chatCore): extrai applyClientUsageBuffer (buffer/estimate de usage non-streaming, #3501) (#4832) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 6/13) --- open-sse/handlers/chatCore.ts | 17 +--- .../handlers/chatCore/clientUsageBuffer.ts | 54 +++++++++++++ .../unit/chatcore-client-usage-buffer.test.ts | 78 +++++++++++++++++++ 3 files changed, 134 insertions(+), 15 deletions(-) create mode 100644 open-sse/handlers/chatCore/clientUsageBuffer.ts create mode 100644 tests/unit/chatcore-client-usage-buffer.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 8331cd578022..201b8957f22b 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -5,6 +5,7 @@ import { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts"; export { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts"; import { checkIdempotencyCache } from "./chatCore/idempotency.ts"; import { checkSemanticCache } from "./chatCore/semanticCache.ts"; +import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; import { getHeaderValueCaseInsensitive, @@ -68,7 +69,6 @@ import { resolveStreamReadinessTimeout } from "../utils/streamReadinessPolicy.ts import { createStreamController, pipeWithDisconnect } from "../utils/streamHandler.ts"; import * as streamFailure from "../utils/streamFailureFinalization.ts"; import { createSseHeartbeatTransform, shapeForClientFormat } from "../utils/sseHeartbeat.ts"; -import { addBufferToUsage, filterUsageForFormat, estimateUsage } from "../utils/usageTracking.ts"; import { refreshWithRetry, isUnrecoverableRefreshError, @@ -3470,20 +3470,7 @@ export async function handleChatCore({ translatedResponse = sanitizeOpenAIResponse(translatedResponse); } - // Add buffer and filter usage for client (to prevent CLI context errors) - if (translatedResponse?.usage) { - const buffered = addBufferToUsage(translatedResponse.usage); - translatedResponse.usage = filterUsageForFormat(buffered, clientResponseFormat); - } else { - // Fallback: estimate usage when provider returned no usage block - const contentLength = JSON.stringify( - translatedResponse?.choices?.[0]?.message?.content || "" - ).length; - if (contentLength > 0) { - const estimated = estimateUsage(body, contentLength, clientResponseFormat); - translatedResponse.usage = filterUsageForFormat(estimated, clientResponseFormat); - } - } + applyClientUsageBuffer(translatedResponse, body, clientResponseFormat); if (memoryOwnerId && memorySettings?.enabled && memorySettings.maxTokens > 0) { const requestMemoryText = extractMemoryTextFromRequestBody(body as Record); diff --git a/open-sse/handlers/chatCore/clientUsageBuffer.ts b/open-sse/handlers/chatCore/clientUsageBuffer.ts new file mode 100644 index 000000000000..12101b73d0fb --- /dev/null +++ b/open-sse/handlers/chatCore/clientUsageBuffer.ts @@ -0,0 +1,54 @@ +/** + * chatCore client usage buffer/estimate (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's non-streaming success path: add a buffer to the response usage + * and filter it for the client format (to prevent CLI context errors); if the provider returned no + * usage block, fall back to estimating from the serialized content length. Mutates + * `translatedResponse.usage` in place — byte-identical to the previous inline block, including the + * `?.usage` guard, the `JSON.stringify(... || "")` content-length, and the `> 0` estimate gate. + */ +import { + addBufferToUsage as defaultAddBuffer, + filterUsageForFormat as defaultFilterUsage, + estimateUsage as defaultEstimateUsage, +} from "../../utils/usageTracking.ts"; + +type ResponseLike = { + usage?: unknown; + choices?: Array<{ message?: { content?: unknown } }>; +} | null | undefined; + +export interface ClientUsageBufferDeps { + addBufferToUsage: typeof defaultAddBuffer; + filterUsageForFormat: typeof defaultFilterUsage; + estimateUsage: typeof defaultEstimateUsage; +} + +const DEFAULT_DEPS: ClientUsageBufferDeps = { + addBufferToUsage: defaultAddBuffer, + filterUsageForFormat: defaultFilterUsage, + estimateUsage: defaultEstimateUsage, +}; + +export function applyClientUsageBuffer( + translatedResponse: ResponseLike, + body: unknown, + clientResponseFormat: unknown, + deps: ClientUsageBufferDeps = DEFAULT_DEPS +): void { + // Add buffer and filter usage for client (to prevent CLI context errors) + if (translatedResponse?.usage) { + const buffered = deps.addBufferToUsage(translatedResponse.usage); + translatedResponse.usage = deps.filterUsageForFormat(buffered, clientResponseFormat); + } else { + // Fallback: estimate usage when provider returned no usage block + const contentLength = JSON.stringify( + translatedResponse?.choices?.[0]?.message?.content || "" + ).length; + if (contentLength > 0) { + const estimated = deps.estimateUsage(body, contentLength, clientResponseFormat); + translatedResponse.usage = deps.filterUsageForFormat(estimated, clientResponseFormat); + } + } +} diff --git a/tests/unit/chatcore-client-usage-buffer.test.ts b/tests/unit/chatcore-client-usage-buffer.test.ts new file mode 100644 index 000000000000..ab58ab4fd2b9 --- /dev/null +++ b/tests/unit/chatcore-client-usage-buffer.test.ts @@ -0,0 +1,78 @@ +// Characterization of applyClientUsageBuffer — the non-streaming usage buffer/estimate block +// extracted from handleChatCore (chatCore god-file decomposition, #3501). Deps are injected so the +// buffer-vs-estimate branch and the in-place mutation of translatedResponse.usage are observable. +// Locks: usage present → buffer+filter; usage absent → estimate from content length; empty content +// (length 2 from JSON.stringify("")) still estimates; the mutation target is translatedResponse. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { applyClientUsageBuffer } = await import( + "../../open-sse/handlers/chatCore/clientUsageBuffer.ts" +); + +function makeDeps(overrides: Record = {}) { + const calls = { buffer: [] as unknown[], estimate: [] as unknown[], filter: [] as unknown[] }; + const deps = { + addBufferToUsage: (u: unknown) => { + calls.buffer.push(u); + return { ...(u as object), _buffered: true }; + }, + estimateUsage: (...a: unknown[]) => { + calls.estimate.push(a); + return { _estimated: true }; + }, + filterUsageForFormat: (u: unknown, _fmt: unknown) => { + calls.filter.push(u); + return { ...(u as object), _filtered: true }; + }, + ...overrides, + } as Parameters[3]; + return { deps, calls }; +} + +test("usage present → buffer then filter, mutates in place", () => { + const { deps, calls } = makeDeps(); + const resp: Record = { usage: { prompt_tokens: 5 } }; + applyClientUsageBuffer(resp, { messages: [] }, "openai", deps); + assert.equal(calls.buffer.length, 1); + assert.equal(calls.estimate.length, 0); + assert.equal((resp.usage as Record)._buffered, true); + assert.equal((resp.usage as Record)._filtered, true); +}); + +test("no usage but content present → estimate then filter", () => { + const { deps, calls } = makeDeps(); + const resp: Record = { + choices: [{ message: { content: "hello world" } }], + }; + applyClientUsageBuffer(resp, { messages: [] }, "openai", deps); + assert.equal(calls.buffer.length, 0); + assert.equal(calls.estimate.length, 1); + assert.equal((resp.usage as Record)._estimated, true); + assert.equal((resp.usage as Record)._filtered, true); + // estimateUsage receives (body, contentLength, format) + const args = calls.estimate[0] as unknown[]; + assert.equal(args[2], "openai"); + assert.equal(typeof args[1], "number"); +}); + +test("empty content → JSON.stringify('') length 2 > 0 still estimates", () => { + const { deps, calls } = makeDeps(); + const resp: Record = {}; + applyClientUsageBuffer(resp, {}, "claude", deps); + // content "" → JSON.stringify("") = '""' length 2 → contentLength 2 > 0 + assert.equal(calls.estimate.length, 1); + const args = calls.estimate[0] as unknown[]; + assert.equal(args[1], 2); +}); + +test("content length is computed from choices[0].message.content", () => { + const { deps, calls } = makeDeps(); + const resp: Record = { + choices: [{ message: { content: "abc" } }], + }; + applyClientUsageBuffer(resp, {}, "openai", deps); + // JSON.stringify("abc") = '"abc"' → length 5 + const args = calls.estimate[0] as unknown[]; + assert.equal(args[1], 5); +}); From 5c0b29e2b3707aee9adf255330c798eb9f222444 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:02:12 -0300 Subject: [PATCH 008/120] refactor(chatCore): extrai buildPostCallGuardrailContext (contexto guardrail post-call, #3501) (#4831) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 7/13) --- open-sse/handlers/chatCore.ts | 22 +++--- .../chatCore/postCallGuardrailContext.ts | 47 ++++++++++++ ...hatcore-postcall-guardrail-context.test.ts | 72 +++++++++++++++++++ 3 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 open-sse/handlers/chatCore/postCallGuardrailContext.ts create mode 100644 tests/unit/chatcore-postcall-guardrail-context.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 201b8957f22b..0ca12ce444e8 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -6,6 +6,7 @@ export { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts"; import { checkIdempotencyCache } from "./chatCore/idempotency.ts"; import { checkSemanticCache } from "./chatCore/semanticCache.ts"; import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts"; +import { buildPostCallGuardrailContext } from "./chatCore/postCallGuardrailContext.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; import { getHeaderValueCaseInsensitive, @@ -200,7 +201,7 @@ import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from import { getProviderCredentials, extractSessionAffinityKey } from "@/sse/services/auth"; import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity"; import { getCacheControlSettings } from "@/lib/cacheControlSettings"; -import { guardrailRegistry, resolveDisabledGuardrails } from "@/lib/guardrails"; +import { guardrailRegistry } from "@/lib/guardrails"; import { shouldPreserveCacheControl } from "../utils/cacheControlPolicy.ts"; import { getCachedSettings } from "@/lib/db/readCache"; import { applyCodexGlobalFastServiceTier } from "@/lib/providers/codexFastTier"; @@ -3503,23 +3504,16 @@ export async function handleChatCore({ ); } - const guardrailContext = { + const guardrailContext = buildPostCallGuardrailContext({ apiKeyInfo, - disabledGuardrails: resolveDisabledGuardrails({ - apiKeyInfo: (apiKeyInfo as Record | null) ?? null, - body, - headers: (clientRawRequest?.headers as Headers | Record | null) ?? null, - }), - endpoint: clientRawRequest?.endpoint || null, - headers: (clientRawRequest?.headers as Headers | Record | null) ?? null, + body, + clientRawRequest, log, - method: "POST", model, provider, - sourceFormat: responsePayloadFormat, - stream: false, - targetFormat: clientResponseFormat, - } as const; + responsePayloadFormat, + clientResponseFormat, + }); const postCallGuardrails = await guardrailRegistry.runPostCallHooks( translatedResponse, guardrailContext diff --git a/open-sse/handlers/chatCore/postCallGuardrailContext.ts b/open-sse/handlers/chatCore/postCallGuardrailContext.ts new file mode 100644 index 000000000000..001c9c2f8036 --- /dev/null +++ b/open-sse/handlers/chatCore/postCallGuardrailContext.ts @@ -0,0 +1,47 @@ +/** + * chatCore post-call guardrail context builder (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's non-streaming success path: assemble the context object passed to + * `guardrailRegistry.runPostCallHooks`. Pure value builder — no side effects, no early-returns. The + * `disabledGuardrails` field is resolved via `resolveDisabledGuardrails` (injectable for tests). + * Behaviour is byte-identical to the previous inline literal, including the `method: "POST"` / + * `stream: false` constants and the headers/endpoint null-coalescing. + */ +import { resolveDisabledGuardrails as defaultResolveDisabled } from "@/lib/guardrails"; + +type LoggerLike = unknown; +type HeadersLike = Headers | Record | null; + +export function buildPostCallGuardrailContext( + args: { + apiKeyInfo: unknown; + body: unknown; + clientRawRequest: { headers?: unknown; endpoint?: unknown } | null | undefined; + log?: LoggerLike; + model: string | null | undefined; + provider: string | null | undefined; + responsePayloadFormat: unknown; + clientResponseFormat: unknown; + }, + resolveDisabledGuardrails: typeof defaultResolveDisabled = defaultResolveDisabled +) { + const headers = (args.clientRawRequest?.headers as HeadersLike) ?? null; + return { + apiKeyInfo: args.apiKeyInfo, + disabledGuardrails: resolveDisabledGuardrails({ + apiKeyInfo: (args.apiKeyInfo as Record | null) ?? null, + body: args.body, + headers, + }), + endpoint: args.clientRawRequest?.endpoint || null, + headers, + log: args.log, + method: "POST", + model: args.model, + provider: args.provider, + sourceFormat: args.responsePayloadFormat, + stream: false, + targetFormat: args.clientResponseFormat, + } as const; +} diff --git a/tests/unit/chatcore-postcall-guardrail-context.test.ts b/tests/unit/chatcore-postcall-guardrail-context.test.ts new file mode 100644 index 000000000000..c539e7e1ea13 --- /dev/null +++ b/tests/unit/chatcore-postcall-guardrail-context.test.ts @@ -0,0 +1,72 @@ +// Characterization of buildPostCallGuardrailContext — the context object passed to +// guardrailRegistry.runPostCallHooks, extracted from handleChatCore (chatCore god-file +// decomposition, #3501). resolveDisabledGuardrails is injected so the mapping is observable in +// isolation. Locks: the field mapping (sourceFormat/targetFormat), the constants (method "POST", +// stream false), endpoint/headers null-coalescing, and that disabled-resolution gets apiKeyInfo/ +// body/headers. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { buildPostCallGuardrailContext } = await import( + "../../open-sse/handlers/chatCore/postCallGuardrailContext.ts" +); + +function baseArgs(overrides: Record = {}) { + return { + apiKeyInfo: { id: "key-1" }, + body: { messages: [] }, + clientRawRequest: { headers: { "x-test": "1" }, endpoint: "/v1/chat/completions" }, + log: { tag: "log" }, + model: "gpt-x", + provider: "openai", + responsePayloadFormat: "openai", + clientResponseFormat: "claude", + ...overrides, + } as Parameters[0]; +} + +test("maps fields, constants, and source/target formats", () => { + const seen: unknown[] = []; + const ctx = buildPostCallGuardrailContext(baseArgs(), (arg: unknown) => { + seen.push(arg); + return ["g1"]; + }); + assert.equal(ctx.method, "POST"); + assert.equal(ctx.stream, false); + assert.equal(ctx.model, "gpt-x"); + assert.equal(ctx.provider, "openai"); + assert.equal(ctx.sourceFormat, "openai"); + assert.equal(ctx.targetFormat, "claude"); + assert.equal(ctx.endpoint, "/v1/chat/completions"); + assert.deepEqual(ctx.disabledGuardrails, ["g1"]); + // resolveDisabledGuardrails received apiKeyInfo/body/headers + assert.deepEqual(seen[0], { + apiKeyInfo: { id: "key-1" }, + body: { messages: [] }, + headers: { "x-test": "1" }, + }); +}); + +test("null clientRawRequest → endpoint/headers null", () => { + const ctx = buildPostCallGuardrailContext(baseArgs({ clientRawRequest: null }), () => []); + assert.equal(ctx.endpoint, null); + assert.equal(ctx.headers, null); +}); + +test("missing endpoint → null (|| null), headers passes through", () => { + const ctx = buildPostCallGuardrailContext( + baseArgs({ clientRawRequest: { headers: { a: "b" } } }), + () => [] + ); + assert.equal(ctx.endpoint, null); + assert.deepEqual(ctx.headers, { a: "b" }); +}); + +test("null apiKeyInfo coalesces to null for resolveDisabledGuardrails", () => { + let received: { apiKeyInfo?: unknown } = {}; + buildPostCallGuardrailContext(baseArgs({ apiKeyInfo: null }), (arg: { apiKeyInfo?: unknown }) => { + received = arg; + return []; + }); + assert.equal(received.apiKeyInfo, null); +}); From 1244e4903b9d30c4add8384897b1de5678dfe170 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:02:22 -0300 Subject: [PATCH 009/120] refactor(chatCore): extrai storeSemanticCacheResponse (cache-store non-streaming, #3501) (#4828) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 8/13) --- open-sse/handlers/chatCore.ts | 27 ++--- .../handlers/chatCore/semanticCacheStore.ts | 73 ++++++++++++ .../chatcore-semantic-cache-store.test.ts | 109 ++++++++++++++++++ 3 files changed, 193 insertions(+), 16 deletions(-) create mode 100644 open-sse/handlers/chatCore/semanticCacheStore.ts create mode 100644 tests/unit/chatcore-semantic-cache-store.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 0ca12ce444e8..fee7b551a374 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -7,6 +7,7 @@ import { checkIdempotencyCache } from "./chatCore/idempotency.ts"; import { checkSemanticCache } from "./chatCore/semanticCache.ts"; import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts"; import { buildPostCallGuardrailContext } from "./chatCore/postCallGuardrailContext.ts"; +import { storeSemanticCacheResponse } from "./chatCore/semanticCacheStore.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; import { getHeaderValueCaseInsensitive, @@ -3563,22 +3564,16 @@ export async function handleChatCore({ } // ── Phase 9.1: Cache store (non-streaming, temp=0) ── - if ( - semanticCacheEnabled && - isCacheableForWrite(body, clientRawRequest?.headers) && - isSmallEnoughForSemanticCache(translatedResponse) - ) { - const signature = generateSignature( - model, - body.messages ?? body.input, - body.temperature, - body.top_p, - apiKeyInfo?.id ?? undefined - ); - const tokensSaved = usage?.prompt_tokens + usage?.completion_tokens || 0; - setCachedResponse(signature, model, translatedResponse, tokensSaved); - log?.debug?.("CACHE", `Stored response for ${model} (${tokensSaved} tokens)`); - } + storeSemanticCacheResponse({ + enabled: semanticCacheEnabled, + body, + headers: clientRawRequest?.headers, + translatedResponse, + model, + apiKeyId: apiKeyInfo?.id ?? undefined, + usage, + log, + }); // ── Phase 9.2: Save for idempotency ── // Reuse the key resolved by checkIdempotencyCache() above (single derivation per diff --git a/open-sse/handlers/chatCore/semanticCacheStore.ts b/open-sse/handlers/chatCore/semanticCacheStore.ts new file mode 100644 index 000000000000..8d119a863ff5 --- /dev/null +++ b/open-sse/handlers/chatCore/semanticCacheStore.ts @@ -0,0 +1,73 @@ +/** + * chatCore semantic-cache store (Quality Gate v2 / Fase 9 — chatCore god-file decomposition, + * #3501). + * + * Extracted from handleChatCore's non-streaming success path (Phase 9.1): when semantic caching is + * enabled and the request/response are cacheable, store the translated response under its signature + * so a later temp=0 request can be served from cache. Side-effect only (cache write + debug log); + * no early-return, no outer-variable reassignment. Behaviour is byte-identical to the previous + * inline block, including the `prompt + completion || 0` token-saved precedence. + */ +import { + generateSignature as defaultGenerateSignature, + setCachedResponse as defaultSetCachedResponse, + isCacheableForWrite as defaultIsCacheableForWrite, +} from "@/lib/semanticCache"; +import { isSmallEnoughForSemanticCache as defaultIsSmallEnough } from "../../utils/estimateSize.ts"; + +type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; + +type CacheBody = { + messages?: unknown; + input?: unknown; + temperature?: unknown; + top_p?: unknown; +}; + +type UsageLike = { prompt_tokens?: number; completion_tokens?: number } | null | undefined; + +export interface SemanticCacheStoreDeps { + isCacheableForWrite: typeof defaultIsCacheableForWrite; + isSmallEnoughForSemanticCache: typeof defaultIsSmallEnough; + generateSignature: typeof defaultGenerateSignature; + setCachedResponse: typeof defaultSetCachedResponse; +} + +const DEFAULT_DEPS: SemanticCacheStoreDeps = { + isCacheableForWrite: defaultIsCacheableForWrite, + isSmallEnoughForSemanticCache: defaultIsSmallEnough, + generateSignature: defaultGenerateSignature, + setCachedResponse: defaultSetCachedResponse, +}; + +export function storeSemanticCacheResponse( + args: { + enabled: boolean; + body: CacheBody; + headers: unknown; + translatedResponse: unknown; + model: string; + apiKeyId?: string | number; + usage?: UsageLike; + log?: LoggerLike; + }, + deps: SemanticCacheStoreDeps = DEFAULT_DEPS +): void { + if ( + !args.enabled || + !deps.isCacheableForWrite(args.body, args.headers) || + !deps.isSmallEnoughForSemanticCache(args.translatedResponse) + ) { + return; + } + const signature = deps.generateSignature( + args.model, + args.body.messages ?? args.body.input, + args.body.temperature, + args.body.top_p, + args.apiKeyId ?? undefined + ); + const tokensSaved = args.usage?.prompt_tokens + args.usage?.completion_tokens || 0; + deps.setCachedResponse(signature, args.model, args.translatedResponse, tokensSaved); + args.log?.debug?.("CACHE", `Stored response for ${args.model} (${tokensSaved} tokens)`); +} diff --git a/tests/unit/chatcore-semantic-cache-store.test.ts b/tests/unit/chatcore-semantic-cache-store.test.ts new file mode 100644 index 000000000000..068b2d28cce2 --- /dev/null +++ b/tests/unit/chatcore-semantic-cache-store.test.ts @@ -0,0 +1,109 @@ +// Characterization of storeSemanticCacheResponse — the Phase 9.1 non-streaming semantic-cache +// store extracted from handleChatCore (chatCore god-file decomposition, #3501). Deps are injected +// so the gating logic + signature derivation are observable without the real cache backend. +// Locks: the 3-way gate (enabled + cacheable-for-write + small-enough), the signature args +// (messages ?? input, temperature, top_p, apiKeyId), and the `prompt + completion || 0` token count. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { storeSemanticCacheResponse } = await import( + "../../open-sse/handlers/chatCore/semanticCacheStore.ts" +); + +type Stored = { sig: unknown; model: string; response: unknown; tokens: number }; + +function makeDeps(overrides: Record = {}) { + const stored: Stored[] = []; + const calls = { cacheable: 0, small: 0, signature: 0 }; + const deps = { + isCacheableForWrite: (..._a: unknown[]) => { + calls.cacheable++; + return true; + }, + isSmallEnoughForSemanticCache: (..._a: unknown[]) => { + calls.small++; + return true; + }, + generateSignature: (...a: unknown[]) => { + calls.signature++; + return `sig:${JSON.stringify(a)}`; + }, + setCachedResponse: (sig: unknown, model: string, response: unknown, tokens: number) => { + stored.push({ sig, model, response, tokens }); + }, + ...overrides, + } as Parameters[1]; + return { deps, stored, calls }; +} + +function baseArgs(overrides: Record = {}) { + return { + enabled: true, + body: { messages: [{ role: "user", content: "hi" }], temperature: 0, top_p: 1 }, + headers: undefined, + translatedResponse: { id: "resp-1" }, + model: "gpt-x", + apiKeyId: "key-1", + usage: { prompt_tokens: 10, completion_tokens: 5 }, + log: undefined, + ...overrides, + } as Parameters[0]; +} + +test("happy path → stores under a signature, tokensSaved = prompt + completion", () => { + const { deps, stored } = makeDeps(); + storeSemanticCacheResponse(baseArgs(), deps); + assert.equal(stored.length, 1); + assert.equal(stored[0].model, "gpt-x"); + assert.deepEqual(stored[0].response, { id: "resp-1" }); + assert.equal(stored[0].tokens, 15); +}); + +test("disabled → no store, no gate calls past enabled", () => { + const { deps, stored, calls } = makeDeps(); + storeSemanticCacheResponse(baseArgs({ enabled: false }), deps); + assert.equal(stored.length, 0); + assert.equal(calls.cacheable, 0); +}); + +test("not cacheable-for-write → no store", () => { + const { deps, stored } = makeDeps({ isCacheableForWrite: () => false }); + storeSemanticCacheResponse(baseArgs(), deps); + assert.equal(stored.length, 0); +}); + +test("too large → no store", () => { + const { deps, stored } = makeDeps({ isSmallEnoughForSemanticCache: () => false }); + storeSemanticCacheResponse(baseArgs(), deps); + assert.equal(stored.length, 0); +}); + +test("signature uses messages when present, with model/temperature/top_p/apiKeyId", () => { + const { deps, stored } = makeDeps(); + storeSemanticCacheResponse(baseArgs(), deps); + const sig = stored[0].sig as string; + assert.ok(sig.includes("gpt-x")); + assert.ok(sig.includes("key-1")); +}); + +test("falls back to body.input when messages absent", () => { + let captured: unknown[] = []; + const { deps } = makeDeps({ + generateSignature: (...a: unknown[]) => { + captured = a; + return "sig"; + }, + }); + storeSemanticCacheResponse( + baseArgs({ body: { input: "the-input", temperature: 0, top_p: 1 } }), + deps + ); + // args: (model, messages ?? input, temperature, top_p, apiKeyId) + assert.equal(captured[1], "the-input"); +}); + +test("missing usage → tokensSaved coerces to 0 (NaN || 0)", () => { + const { deps, stored } = makeDeps(); + storeSemanticCacheResponse(baseArgs({ usage: undefined }), deps); + assert.equal(stored[0].tokens, 0); +}); From 867e9043f57e173568e670a971cc056c1bb85143 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:02:32 -0300 Subject: [PATCH 010/120] refactor(chatCore): extrai buildNonStreamingResponseHeaders (headers de resposta non-streaming, #3501) (#4835) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 9/13) --- open-sse/handlers/chatCore.ts | 19 ++--- .../chatCore/nonStreamingResponseHeaders.ts | 46 ++++++++++++ ...core-nonstreaming-response-headers.test.ts | 73 +++++++++++++++++++ 3 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts create mode 100644 tests/unit/chatcore-nonstreaming-response-headers.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index fee7b551a374..6fd5168e0e50 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -8,6 +8,7 @@ import { checkSemanticCache } from "./chatCore/semanticCache.ts"; import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts"; import { buildPostCallGuardrailContext } from "./chatCore/postCallGuardrailContext.ts"; import { storeSemanticCacheResponse } from "./chatCore/semanticCacheStore.ts"; +import { buildNonStreamingResponseHeaders } from "./chatCore/nonStreamingResponseHeaders.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; import { getHeaderValueCaseInsensitive, @@ -161,7 +162,6 @@ import { saveRequestUsage, trackPendingRequest, appendRequestLog } from "@/lib/u import { finalizePendingScope, updatePendingScope } from "@/lib/usage/pendingRequestScope"; import { recordCost } from "@/domain/costRules"; import { calculateCost } from "@/lib/usage/costCalculator"; -import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; import { buildClaudePassthroughToolNameMap, restoreClaudePassthroughToolNames, @@ -3619,22 +3619,15 @@ export async function handleChatCore({ providerResponse: responseBody, clientResponse: translatedResponse, }); - const responseHeaders: Record = { - "Content-Type": "application/json", - [OMNIROUTE_RESPONSE_HEADERS.cache]: "MISS", - }; - attachOmniRouteMetaHeaders(responseHeaders, { + const responseHeaders = buildNonStreamingResponseHeaders({ provider, model, - cacheHit: false, - latencyMs: Date.now() - startTime, - usage: responseUsage, - costUsd: estimatedCost, + startTime, + responseUsage, + estimatedCost, requestId: skillRequestId, + compressionResponseMeta, }); - if (compressionResponseMeta) { - responseHeaders[OMNIROUTE_RESPONSE_HEADERS.compression] = compressionResponseMeta; - } // #1311: echo the requested alias/combo name in the non-streaming response model. if (echoModel) echoModelInObject(translatedResponse, echoModel); return { diff --git a/open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts b/open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts new file mode 100644 index 000000000000..4306ace46435 --- /dev/null +++ b/open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts @@ -0,0 +1,46 @@ +/** + * chatCore non-streaming success response headers (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's non-streaming success path: build the response header map for a + * cache-MISS JSON response — the static Content-Type + cache marker, the OmniRoute meta headers + * (provider/model/latency/usage/cost/request-id), and the optional compression header. Pure builder + * (returns a fresh map; only mutates the map it owns). Behaviour is byte-identical to the previous + * inline block, including `latencyMs: now - startTime`. + */ +import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; +import { attachOmniRouteMetaHeaders as defaultAttachMeta } from "@/domain/omnirouteResponseMeta"; + +export function buildNonStreamingResponseHeaders( + args: { + provider: string | null | undefined; + model: string | null | undefined; + startTime: number; + responseUsage: unknown; + estimatedCost: number; + requestId: unknown; + compressionResponseMeta?: string | null | undefined; + }, + deps: { attachOmniRouteMetaHeaders: typeof defaultAttachMeta; now: () => number } = { + attachOmniRouteMetaHeaders: defaultAttachMeta, + now: Date.now, + } +): Record { + const responseHeaders: Record = { + "Content-Type": "application/json", + [OMNIROUTE_RESPONSE_HEADERS.cache]: "MISS", + }; + deps.attachOmniRouteMetaHeaders(responseHeaders, { + provider: args.provider, + model: args.model, + cacheHit: false, + latencyMs: deps.now() - args.startTime, + usage: args.responseUsage, + costUsd: args.estimatedCost, + requestId: args.requestId, + }); + if (args.compressionResponseMeta) { + responseHeaders[OMNIROUTE_RESPONSE_HEADERS.compression] = args.compressionResponseMeta; + } + return responseHeaders; +} diff --git a/tests/unit/chatcore-nonstreaming-response-headers.test.ts b/tests/unit/chatcore-nonstreaming-response-headers.test.ts new file mode 100644 index 000000000000..d5a7a74044ac --- /dev/null +++ b/tests/unit/chatcore-nonstreaming-response-headers.test.ts @@ -0,0 +1,73 @@ +// Characterization of buildNonStreamingResponseHeaders — the cache-MISS response header builder +// extracted from handleChatCore's non-streaming success path (chatCore god-file decomposition, +// #3501). attachOmniRouteMetaHeaders + now are injected so the static headers, the meta payload, +// and the optional compression header are observable. Locks: Content-Type + cache MISS, latencyMs = +// now - startTime, and the compression header only when meta is present. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { buildNonStreamingResponseHeaders } = await import( + "../../open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts" +); + +function makeDeps(now = 1000) { + const metaCalls: Array<{ headers: Record; meta: Record }> = []; + const deps = { + attachOmniRouteMetaHeaders: (headers: Record, meta: Record) => { + metaCalls.push({ headers, meta }); + headers["x-omniroute-meta"] = "attached"; + }, + now: () => now, + } as Parameters[1]; + return { deps, metaCalls }; +} + +function baseArgs(overrides: Record = {}) { + return { + provider: "openai", + model: "gpt-x", + startTime: 600, + responseUsage: { prompt_tokens: 5 }, + estimatedCost: 0.0012, + requestId: "req-1", + compressionResponseMeta: undefined, + ...overrides, + } as Parameters[0]; +} + +test("static headers: Content-Type json + cache MISS", () => { + const { deps } = makeDeps(); + const h = buildNonStreamingResponseHeaders(baseArgs(), deps); + assert.equal(h["Content-Type"], "application/json"); + // cache marker key/value + assert.ok(Object.values(h).includes("MISS")); +}); + +test("meta receives provider/model/cacheHit false/latency/usage/cost/requestId", () => { + const { deps, metaCalls } = makeDeps(1000); + buildNonStreamingResponseHeaders(baseArgs({ startTime: 600 }), deps); + assert.equal(metaCalls.length, 1); + const meta = metaCalls[0].meta; + assert.equal(meta.provider, "openai"); + assert.equal(meta.model, "gpt-x"); + assert.equal(meta.cacheHit, false); + assert.equal(meta.latencyMs, 400); // now 1000 - startTime 600 + assert.deepEqual(meta.usage, { prompt_tokens: 5 }); + assert.equal(meta.costUsd, 0.0012); + assert.equal(meta.requestId, "req-1"); +}); + +test("no compression meta → no compression header", () => { + const { deps } = makeDeps(); + const h = buildNonStreamingResponseHeaders(baseArgs({ compressionResponseMeta: undefined }), deps); + assert.ok(!Object.values(h).includes("engine:x")); +}); + +test("compression meta present → compression header set to that value", () => { + const { deps } = makeDeps(); + const h = buildNonStreamingResponseHeaders( + baseArgs({ compressionResponseMeta: "engine:x; source=header" }), + deps + ); + assert.ok(Object.values(h).includes("engine:x; source=header")); +}); From c69bd6a4ebdc92aa2abf0e9b40956cdd41940968 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:02:42 -0300 Subject: [PATCH 011/120] =?UTF-8?q?refactor(chatCore):=20extrai=20maybeCon?= =?UTF-8?q?vertJsonBodyToSse=20(#3089=20JSON=E2=86=92SSE=20streaming,=20#3?= =?UTF-8?q?501)=20(#4833)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 (#3501 chatCore extraction stack 10/13) --- open-sse/handlers/chatCore.ts | 37 +--------- open-sse/handlers/chatCore/jsonBodyToSse.ts | 68 ++++++++++++++++++ tests/unit/chatcore-json-body-to-sse.test.ts | 73 ++++++++++++++++++++ 3 files changed, 143 insertions(+), 35 deletions(-) create mode 100644 open-sse/handlers/chatCore/jsonBodyToSse.ts create mode 100644 tests/unit/chatcore-json-body-to-sse.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 6fd5168e0e50..3f96a6c699e6 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -9,6 +9,7 @@ import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts"; import { buildPostCallGuardrailContext } from "./chatCore/postCallGuardrailContext.ts"; import { storeSemanticCacheResponse } from "./chatCore/semanticCacheStore.ts"; import { buildNonStreamingResponseHeaders } from "./chatCore/nonStreamingResponseHeaders.ts"; +import { maybeConvertJsonBodyToSse } from "./chatCore/jsonBodyToSse.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; import { getHeaderValueCaseInsensitive, @@ -67,7 +68,6 @@ import { withBodyTimeout, } from "../utils/stream.ts"; import { ensureStreamReadiness } from "../utils/streamReadiness.ts"; -import { synthesizeOpenAiSseFromJson } from "../utils/jsonToSse.ts"; import { resolveStreamReadinessTimeout } from "../utils/streamReadinessPolicy.ts"; import { createStreamController, pipeWithDisconnect } from "../utils/streamHandler.ts"; import * as streamFailure from "../utils/streamFailureFinalization.ts"; @@ -3646,40 +3646,7 @@ export async function handleChatCore({ // though it carried valid content/reasoning_content. Detect a JSON (non-SSE) // upstream body and synthesize an equivalent OpenAI SSE stream so the // streaming pipeline (and the client) get a valid stream. - { - const upstreamContentType = (providerResponse.headers.get("content-type") || "").toLowerCase(); - const isNonSseJsonBody = - !!providerResponse.body && - upstreamContentType.includes("application/json") && - !upstreamContentType.includes("text/event-stream") && - !upstreamContentType.includes("application/x-ndjson"); - if (isNonSseJsonBody) { - const jsonText = await withBodyTimeout(providerResponse.text()); - const synthesizedSse = synthesizeOpenAiSseFromJson(jsonText); - const rebuiltHeaders = new Headers(providerResponse.headers); - rebuiltHeaders.delete("content-length"); - if (synthesizedSse) { - log?.debug?.( - "STREAM", - `Upstream returned application/json on a streaming request — converting to SSE (${provider}/${model})` - ); - rebuiltHeaders.set("content-type", "text/event-stream"); - providerResponse = new Response(synthesizedSse, { - status: providerResponse.status, - statusText: providerResponse.statusText, - headers: rebuiltHeaders, - }); - } else { - // Not a convertible chat-completion JSON — rebuild the consumed body so - // the existing readiness/error path still runs unchanged. - providerResponse = new Response(jsonText, { - status: providerResponse.status, - statusText: providerResponse.statusText, - headers: rebuiltHeaders, - }); - } - } - } + providerResponse = await maybeConvertJsonBodyToSse(providerResponse, { log, provider, model }); const streamReadinessPolicy = resolveStreamReadinessTimeout({ baseTimeoutMs: STREAM_READINESS_TIMEOUT_MS, provider, diff --git a/open-sse/handlers/chatCore/jsonBodyToSse.ts b/open-sse/handlers/chatCore/jsonBodyToSse.ts new file mode 100644 index 000000000000..fa4db8867df3 --- /dev/null +++ b/open-sse/handlers/chatCore/jsonBodyToSse.ts @@ -0,0 +1,68 @@ +/** + * chatCore non-SSE JSON → SSE conversion (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's streaming entry (#3089): some "reasoning" openai-compatible + * upstreams ignore `stream:true` and return a complete application/json chat-completion body + * instead of an SSE stream. The readiness check only recognizes SSE `data:` frames, so that body + * produced a spurious STREAM_EARLY_EOF / HTTP 502. Detect a JSON (non-SSE) upstream body and + * synthesize an equivalent OpenAI SSE stream so the streaming pipeline gets a valid stream. + * + * Returns the (possibly rebuilt) provider response — unchanged when the body is not a non-SSE JSON + * body, an SSE stream when convertible, or a rebuilt-with-consumed-body response otherwise (so the + * existing readiness/error path still runs unchanged). Behaviour is byte-identical to the previous + * inline block. + */ +import { withBodyTimeout as defaultWithBodyTimeout } from "../../utils/stream.ts"; +import { synthesizeOpenAiSseFromJson as defaultSynthesize } from "../../utils/jsonToSse.ts"; + +type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; + +export interface JsonBodyToSseDeps { + withBodyTimeout: typeof defaultWithBodyTimeout; + synthesizeOpenAiSseFromJson: typeof defaultSynthesize; +} + +const DEFAULT_DEPS: JsonBodyToSseDeps = { + withBodyTimeout: defaultWithBodyTimeout, + synthesizeOpenAiSseFromJson: defaultSynthesize, +}; + +export async function maybeConvertJsonBodyToSse( + providerResponse: Response, + ctx: { log?: LoggerLike; provider: string | null | undefined; model: string | null | undefined }, + deps: JsonBodyToSseDeps = DEFAULT_DEPS +): Promise { + const upstreamContentType = (providerResponse.headers.get("content-type") || "").toLowerCase(); + const isNonSseJsonBody = + !!providerResponse.body && + upstreamContentType.includes("application/json") && + !upstreamContentType.includes("text/event-stream") && + !upstreamContentType.includes("application/x-ndjson"); + if (!isNonSseJsonBody) { + return providerResponse; + } + const jsonText = await deps.withBodyTimeout(providerResponse.text()); + const synthesizedSse = deps.synthesizeOpenAiSseFromJson(jsonText); + const rebuiltHeaders = new Headers(providerResponse.headers); + rebuiltHeaders.delete("content-length"); + if (synthesizedSse) { + ctx.log?.debug?.( + "STREAM", + `Upstream returned application/json on a streaming request — converting to SSE (${ctx.provider}/${ctx.model})` + ); + rebuiltHeaders.set("content-type", "text/event-stream"); + return new Response(synthesizedSse, { + status: providerResponse.status, + statusText: providerResponse.statusText, + headers: rebuiltHeaders, + }); + } + // Not a convertible chat-completion JSON — rebuild the consumed body so the existing + // readiness/error path still runs unchanged. + return new Response(jsonText, { + status: providerResponse.status, + statusText: providerResponse.statusText, + headers: rebuiltHeaders, + }); +} diff --git a/tests/unit/chatcore-json-body-to-sse.test.ts b/tests/unit/chatcore-json-body-to-sse.test.ts new file mode 100644 index 000000000000..ed778e5fec7c --- /dev/null +++ b/tests/unit/chatcore-json-body-to-sse.test.ts @@ -0,0 +1,73 @@ +// Characterization of maybeConvertJsonBodyToSse — the #3089 non-SSE JSON → SSE conversion +// extracted from handleChatCore's streaming entry (chatCore god-file decomposition, #3501). Real +// Response objects + injected deps make the content-type gate, the synthesize-or-rebuild branch, +// and the header rewrites observable. Locks: pass-through for SSE/ndjson/no-body, conversion to +// text/event-stream when synthesizable, and rebuild-with-consumed-body otherwise. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { maybeConvertJsonBodyToSse } = await import( + "../../open-sse/handlers/chatCore/jsonBodyToSse.ts" +); + +const ctx = { log: undefined, provider: "openai", model: "gpt-x" }; + +function makeDeps(synth: (s: string) => string | null) { + return { + withBodyTimeout: async (p: Promise) => p, + synthesizeOpenAiSseFromJson: synth, + } as Parameters[2]; +} + +test("already text/event-stream → returned unchanged (same reference)", async () => { + const resp = new Response("data: {}\n\n", { + headers: { "content-type": "text/event-stream" }, + }); + const out = await maybeConvertJsonBodyToSse(resp, ctx, makeDeps(() => "X")); + assert.equal(out, resp); +}); + +test("application/x-ndjson → returned unchanged", async () => { + const resp = new Response('{"a":1}\n', { headers: { "content-type": "application/x-ndjson" } }); + const out = await maybeConvertJsonBodyToSse(resp, ctx, makeDeps(() => "X")); + assert.equal(out, resp); +}); + +test("no body → returned unchanged", async () => { + const resp = new Response(null, { headers: { "content-type": "application/json" } }); + const out = await maybeConvertJsonBodyToSse(resp, ctx, makeDeps(() => "X")); + assert.equal(out, resp); +}); + +test("application/json + synthesizable → SSE response with text/event-stream, no content-length", async () => { + const resp = new Response('{"choices":[]}', { + headers: { "content-type": "application/json", "content-length": "14" }, + }); + const out = await maybeConvertJsonBodyToSse(resp, ctx, makeDeps(() => "data: synth\n\n")); + assert.notEqual(out, resp); + assert.equal(out.headers.get("content-type"), "text/event-stream"); + assert.equal(out.headers.get("content-length"), null); + assert.equal(await out.text(), "data: synth\n\n"); +}); + +test("application/json + not synthesizable → rebuilt with consumed body, content-type unchanged", async () => { + const resp = new Response('{"not":"chat"}', { + headers: { "content-type": "application/json" }, + }); + const out = await maybeConvertJsonBodyToSse(resp, ctx, makeDeps(() => null)); + assert.notEqual(out, resp); + // not converted → content-type stays application/json (rebuilt headers) + assert.equal(out.headers.get("content-type"), "application/json"); + assert.equal(await out.text(), '{"not":"chat"}'); +}); + +test("status and statusText are preserved on conversion", async () => { + const resp = new Response('{"choices":[]}', { + status: 201, + statusText: "Created", + headers: { "content-type": "application/json" }, + }); + const out = await maybeConvertJsonBodyToSse(resp, ctx, makeDeps(() => "data: x\n\n")); + assert.equal(out.status, 201); + assert.equal(out.statusText, "Created"); +}); From e87d35ceda48e5082fa20c9ffba72574f49740fb Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:02:52 -0300 Subject: [PATCH 012/120] refactor(chatCore): extrai assembleStreamingResponseHeaders (headers de resposta streaming, #3501) (#4836) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 11/13) --- open-sse/handlers/chatCore.ts | 22 +++---- .../chatCore/streamingResponseHeaders.ts | 39 +++++++++++ ...hatcore-streaming-response-headers.test.ts | 65 +++++++++++++++++++ 3 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 open-sse/handlers/chatCore/streamingResponseHeaders.ts create mode 100644 tests/unit/chatcore-streaming-response-headers.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 3f96a6c699e6..d4caf28454d4 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -10,6 +10,7 @@ import { buildPostCallGuardrailContext } from "./chatCore/postCallGuardrailConte import { storeSemanticCacheResponse } from "./chatCore/semanticCacheStore.ts"; import { buildNonStreamingResponseHeaders } from "./chatCore/nonStreamingResponseHeaders.ts"; import { maybeConvertJsonBodyToSse } from "./chatCore/jsonBodyToSse.ts"; +import { assembleStreamingResponseHeaders } from "./chatCore/streamingResponseHeaders.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; import { getHeaderValueCaseInsensitive, @@ -3709,20 +3710,13 @@ export async function handleChatCore({ await onRequestSuccess(); } - const responseHeaders: Record = { - ...buildStreamingResponseHeaders(providerResponse.headers, { - provider, - model, - cacheHit: false, - latencyMs: 0, - usage: null, - costUsd: 0, - }), - "x-omniroute-request-id": pendingRequestId, - }; - if (compressionResponseMeta) { - responseHeaders[OMNIROUTE_RESPONSE_HEADERS.compression] = compressionResponseMeta; - } + const responseHeaders = assembleStreamingResponseHeaders({ + providerHeaders: providerResponse.headers, + provider, + model, + pendingRequestId, + compressionResponseMeta, + }); // Create transform stream with logger for streaming response let transformStream; diff --git a/open-sse/handlers/chatCore/streamingResponseHeaders.ts b/open-sse/handlers/chatCore/streamingResponseHeaders.ts new file mode 100644 index 000000000000..a978a0a212e7 --- /dev/null +++ b/open-sse/handlers/chatCore/streamingResponseHeaders.ts @@ -0,0 +1,39 @@ +/** + * chatCore streaming response headers (Quality Gate v2 / Fase 9 — chatCore god-file decomposition, + * #3501). + * + * Extracted from handleChatCore's streaming success path: assemble the streaming response header map + * — the upstream-derived streaming headers (via buildStreamingResponseHeaders, with zeroed + * latency/usage/cost since those are not yet known at stream start), the per-request id, and the + * optional compression header. Pure builder (returns a fresh map). Behaviour is byte-identical to + * the previous inline block. + */ +import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; +import { buildStreamingResponseHeaders as defaultBuildStreaming } from "./responseHeaders.ts"; + +export function assembleStreamingResponseHeaders( + args: { + providerHeaders: Headers; + provider: string | null | undefined; + model: string | null | undefined; + pendingRequestId: string; + compressionResponseMeta?: string | null | undefined; + }, + buildStreamingResponseHeaders: typeof defaultBuildStreaming = defaultBuildStreaming +): Record { + const responseHeaders: Record = { + ...buildStreamingResponseHeaders(args.providerHeaders, { + provider: args.provider, + model: args.model, + cacheHit: false, + latencyMs: 0, + usage: null, + costUsd: 0, + }), + "x-omniroute-request-id": args.pendingRequestId, + }; + if (args.compressionResponseMeta) { + responseHeaders[OMNIROUTE_RESPONSE_HEADERS.compression] = args.compressionResponseMeta; + } + return responseHeaders; +} diff --git a/tests/unit/chatcore-streaming-response-headers.test.ts b/tests/unit/chatcore-streaming-response-headers.test.ts new file mode 100644 index 000000000000..8b714ca4eb0d --- /dev/null +++ b/tests/unit/chatcore-streaming-response-headers.test.ts @@ -0,0 +1,65 @@ +// Characterization of assembleStreamingResponseHeaders — the streaming response header builder +// extracted from handleChatCore's streaming success path (chatCore god-file decomposition, #3501). +// buildStreamingResponseHeaders is injected so the merge of upstream headers + request-id + the +// optional compression header is observable. Locks: zeroed latency/usage/cost at stream start, the +// x-omniroute-request-id, and the compression header only when meta is present. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { assembleStreamingResponseHeaders } = await import( + "../../open-sse/handlers/chatCore/streamingResponseHeaders.ts" +); + +function makeBuild() { + const calls: Array<{ headers: unknown; meta: Record }> = []; + const build = (headers: unknown, meta: Record) => { + calls.push({ headers, meta }); + return { "x-upstream": "kept" }; + }; + return { build: build as Parameters[1], calls }; +} + +function baseArgs(overrides: Record = {}) { + return { + providerHeaders: new Headers({ "content-type": "text/event-stream" }), + provider: "openai", + model: "gpt-x", + pendingRequestId: "preq-1", + compressionResponseMeta: undefined, + ...overrides, + } as Parameters[0]; +} + +test("merges upstream headers and sets x-omniroute-request-id", () => { + const { build } = makeBuild(); + const h = assembleStreamingResponseHeaders(baseArgs(), build); + assert.equal(h["x-upstream"], "kept"); + assert.equal(h["x-omniroute-request-id"], "preq-1"); +}); + +test("buildStreamingResponseHeaders receives zeroed latency/usage/cost and cacheHit false", () => { + const { build, calls } = makeBuild(); + assembleStreamingResponseHeaders(baseArgs(), build); + assert.equal(calls.length, 1); + assert.equal(calls[0].meta.cacheHit, false); + assert.equal(calls[0].meta.latencyMs, 0); + assert.equal(calls[0].meta.usage, null); + assert.equal(calls[0].meta.costUsd, 0); + assert.equal(calls[0].meta.provider, "openai"); + assert.equal(calls[0].meta.model, "gpt-x"); +}); + +test("no compression meta → no compression header", () => { + const { build } = makeBuild(); + const h = assembleStreamingResponseHeaders(baseArgs({ compressionResponseMeta: undefined }), build); + assert.ok(!Object.values(h).includes("engine:z")); +}); + +test("compression meta present → compression header set", () => { + const { build } = makeBuild(); + const h = assembleStreamingResponseHeaders( + baseArgs({ compressionResponseMeta: "engine:z; source=routing" }), + build + ); + assert.ok(Object.values(h).includes("engine:z; source=routing")); +}); From 2cd5609628e9083e7bfef3513feda472b7ba9ab7 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:03:02 -0300 Subject: [PATCH 013/120] refactor(chatCore): extrai storeStreamingSemanticCacheResponse (cache-store streaming, #3501) (#4829) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 12/13) --- open-sse/handlers/chatCore.ts | 38 +++----- .../chatCore/streamingSemanticCacheStore.ts | 95 ++++++++++++++++++ .../chatcore-streaming-cache-store.test.ts | 96 +++++++++++++++++++ 3 files changed, 203 insertions(+), 26 deletions(-) create mode 100644 open-sse/handlers/chatCore/streamingSemanticCacheStore.ts create mode 100644 tests/unit/chatcore-streaming-cache-store.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d4caf28454d4..ef7b3616f4a9 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -11,6 +11,7 @@ import { storeSemanticCacheResponse } from "./chatCore/semanticCacheStore.ts"; import { buildNonStreamingResponseHeaders } from "./chatCore/nonStreamingResponseHeaders.ts"; import { maybeConvertJsonBodyToSse } from "./chatCore/jsonBodyToSse.ts"; import { assembleStreamingResponseHeaders } from "./chatCore/streamingResponseHeaders.ts"; +import { storeStreamingSemanticCacheResponse } from "./chatCore/streamingSemanticCacheStore.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; import { getHeaderValueCaseInsensitive, @@ -3861,32 +3862,17 @@ export async function handleChatCore({ } // Semantic cache: store assembled streaming response for future cache hits - if ( - semanticCacheEnabled && - streamStatus === 200 && - streamResponseBody && - isCacheableForWrite(body, clientRawRequest?.headers) - ) { - try { - const cleanBody = { ...streamResponseBody }; - delete cleanBody._streamed; - if (!isSmallEnoughForSemanticCache(cleanBody)) return; - const sig = generateSignature( - model, - body.messages ?? body.input, - body.temperature, - body.top_p, - apiKeyInfo?.id ?? undefined - ); - const u = streamUsage as Record | null; - const tokensSaved = - (Number(u?.prompt_tokens ?? 0) || 0) + (Number(u?.completion_tokens ?? 0) || 0); - setCachedResponse(sig, model, cleanBody, tokensSaved); - log?.debug?.("CACHE", `Stored streaming response for ${model} (${tokensSaved} tokens)`); - } catch { - // Cache write failed — non-critical - } - } + storeStreamingSemanticCacheResponse({ + enabled: semanticCacheEnabled, + streamStatus, + streamResponseBody, + body, + headers: clientRawRequest?.headers, + model, + apiKeyId: apiKeyInfo?.id ?? undefined, + streamUsage, + log, + }); }; const streamFailureFinalizers = streamFailure.createStreamFailureFinalizers({ diff --git a/open-sse/handlers/chatCore/streamingSemanticCacheStore.ts b/open-sse/handlers/chatCore/streamingSemanticCacheStore.ts new file mode 100644 index 000000000000..3aa16a978156 --- /dev/null +++ b/open-sse/handlers/chatCore/streamingSemanticCacheStore.ts @@ -0,0 +1,95 @@ +/** + * chatCore streaming semantic-cache store (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's onStreamComplete callback: after a 200 streaming response is + * assembled, store it under its signature so a future temp=0 request can be served from cache. + * Side-effect only (cache write + debug log), wrapped in fail-open try/catch. Behaviour is + * byte-identical to the previous inline block — including the `_streamed` strip, the early + * skip-on-too-large, and the `Number(...) || 0` token accounting. The early return was the last + * statement of the callback, so returning from this helper is equivalent. + */ +import { + generateSignature as defaultGenerateSignature, + setCachedResponse as defaultSetCachedResponse, + isCacheableForWrite as defaultIsCacheableForWrite, +} from "@/lib/semanticCache"; +import { isSmallEnoughForSemanticCache as defaultIsSmallEnough } from "../../utils/estimateSize.ts"; + +type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; + +type CacheBody = { + messages?: unknown; + input?: unknown; + temperature?: unknown; + top_p?: unknown; +}; + +export interface StreamingSemanticCacheStoreDeps { + isCacheableForWrite: typeof defaultIsCacheableForWrite; + isSmallEnoughForSemanticCache: typeof defaultIsSmallEnough; + generateSignature: typeof defaultGenerateSignature; + setCachedResponse: typeof defaultSetCachedResponse; +} + +const DEFAULT_DEPS: StreamingSemanticCacheStoreDeps = { + isCacheableForWrite: defaultIsCacheableForWrite, + isSmallEnoughForSemanticCache: defaultIsSmallEnough, + generateSignature: defaultGenerateSignature, + setCachedResponse: defaultSetCachedResponse, +}; + +interface StreamingCacheArgs { + enabled: boolean; + streamStatus: number; + streamResponseBody: Record | null | undefined; + body: CacheBody; + headers: unknown; + model: string; + apiKeyId?: string | number; + streamUsage?: Record | null; + log?: LoggerLike; +} + +function streamTokensSaved(streamUsage: Record | null | undefined): number { + const u = streamUsage as Record | null; + return (Number(u?.prompt_tokens ?? 0) || 0) + (Number(u?.completion_tokens ?? 0) || 0); +} + +function writeStreamingCacheEntry( + args: StreamingCacheArgs, + deps: StreamingSemanticCacheStoreDeps +): void { + try { + const cleanBody = { ...(args.streamResponseBody as Record) }; + delete cleanBody._streamed; + if (!deps.isSmallEnoughForSemanticCache(cleanBody)) return; + const sig = deps.generateSignature( + args.model, + args.body.messages ?? args.body.input, + args.body.temperature, + args.body.top_p, + args.apiKeyId ?? undefined + ); + const tokensSaved = streamTokensSaved(args.streamUsage); + deps.setCachedResponse(sig, args.model, cleanBody, tokensSaved); + args.log?.debug?.("CACHE", `Stored streaming response for ${args.model} (${tokensSaved} tokens)`); + } catch { + // Cache write failed — non-critical + } +} + +export function storeStreamingSemanticCacheResponse( + args: StreamingCacheArgs, + deps: StreamingSemanticCacheStoreDeps = DEFAULT_DEPS +): void { + if ( + !args.enabled || + args.streamStatus !== 200 || + !args.streamResponseBody || + !deps.isCacheableForWrite(args.body, args.headers) + ) { + return; + } + writeStreamingCacheEntry(args, deps); +} diff --git a/tests/unit/chatcore-streaming-cache-store.test.ts b/tests/unit/chatcore-streaming-cache-store.test.ts new file mode 100644 index 000000000000..8ee5fb264c6a --- /dev/null +++ b/tests/unit/chatcore-streaming-cache-store.test.ts @@ -0,0 +1,96 @@ +// Characterization of storeStreamingSemanticCacheResponse — the streaming Phase 9.1 cache store +// extracted from handleChatCore's onStreamComplete (chatCore god-file decomposition, #3501). Deps +// are injected so the gate + `_streamed` strip + signature + token accounting are observable +// without the real cache backend. Locks: the 4-way gate (enabled + status 200 + body + cacheable), +// the `_streamed` strip, skip-on-too-large, `Number(...) || 0` tokens, and fail-open on throw. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { storeStreamingSemanticCacheResponse } = await import( + "../../open-sse/handlers/chatCore/streamingSemanticCacheStore.ts" +); + +type Stored = { sig: unknown; model: string; body: Record; tokens: number }; + +function makeDeps(overrides: Record = {}) { + const stored: Stored[] = []; + const deps = { + isCacheableForWrite: () => true, + isSmallEnoughForSemanticCache: () => true, + generateSignature: (...a: unknown[]) => `sig:${JSON.stringify(a)}`, + setCachedResponse: (sig: unknown, model: string, body: Record, tokens: number) => + stored.push({ sig, model, body, tokens }), + ...overrides, + } as Parameters[1]; + return { deps, stored }; +} + +function baseArgs(overrides: Record = {}) { + return { + enabled: true, + streamStatus: 200, + streamResponseBody: { id: "resp-1", _streamed: true }, + body: { messages: [{ role: "user", content: "hi" }], temperature: 0, top_p: 1 }, + headers: undefined, + model: "gpt-x", + apiKeyId: "key-1", + streamUsage: { prompt_tokens: 12, completion_tokens: 8 }, + log: undefined, + ...overrides, + } as Parameters[0]; +} + +test("happy path → stores cleaned body (no _streamed), tokens = prompt + completion", () => { + const { deps, stored } = makeDeps(); + storeStreamingSemanticCacheResponse(baseArgs(), deps); + assert.equal(stored.length, 1); + assert.equal(stored[0].model, "gpt-x"); + assert.equal(stored[0].tokens, 20); + assert.equal("_streamed" in stored[0].body, false); + assert.equal(stored[0].body.id, "resp-1"); +}); + +test("non-200 stream status → no store", () => { + const { deps, stored } = makeDeps(); + storeStreamingSemanticCacheResponse(baseArgs({ streamStatus: 500 }), deps); + assert.equal(stored.length, 0); +}); + +test("disabled → no store", () => { + const { deps, stored } = makeDeps(); + storeStreamingSemanticCacheResponse(baseArgs({ enabled: false }), deps); + assert.equal(stored.length, 0); +}); + +test("missing response body → no store", () => { + const { deps, stored } = makeDeps(); + storeStreamingSemanticCacheResponse(baseArgs({ streamResponseBody: null }), deps); + assert.equal(stored.length, 0); +}); + +test("not cacheable-for-write → no store", () => { + const { deps, stored } = makeDeps({ isCacheableForWrite: () => false }); + storeStreamingSemanticCacheResponse(baseArgs(), deps); + assert.equal(stored.length, 0); +}); + +test("too large → no store (early return inside try)", () => { + const { deps, stored } = makeDeps({ isSmallEnoughForSemanticCache: () => false }); + storeStreamingSemanticCacheResponse(baseArgs(), deps); + assert.equal(stored.length, 0); +}); + +test("missing usage → tokens coerce to 0", () => { + const { deps, stored } = makeDeps(); + storeStreamingSemanticCacheResponse(baseArgs({ streamUsage: null }), deps); + assert.equal(stored[0].tokens, 0); +}); + +test("a throwing dep is swallowed (fail-open, non-critical)", () => { + const { deps } = makeDeps({ + setCachedResponse: () => { + throw new Error("cache write boom"); + }, + }); + assert.doesNotThrow(() => storeStreamingSemanticCacheResponse(baseArgs(), deps)); +}); From 5bffefb489816759ace695b5726f585561128361 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:03:12 -0300 Subject: [PATCH 014/120] refactor(chatCore): extrai assembleStreamingPipeline (chain de transforms streaming, #3501) (#4837) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 13/13) --- open-sse/handlers/chatCore.ts | 50 +++------ .../handlers/chatCore/streamingPipeline.ts | 99 +++++++++++++++++ .../unit/chatcore-streaming-pipeline.test.ts | 102 ++++++++++++++++++ 3 files changed, 215 insertions(+), 36 deletions(-) create mode 100644 open-sse/handlers/chatCore/streamingPipeline.ts create mode 100644 tests/unit/chatcore-streaming-pipeline.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index ef7b3616f4a9..5f3c50f872f6 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -12,6 +12,7 @@ import { buildNonStreamingResponseHeaders } from "./chatCore/nonStreamingRespons import { maybeConvertJsonBodyToSse } from "./chatCore/jsonBodyToSse.ts"; import { assembleStreamingResponseHeaders } from "./chatCore/streamingResponseHeaders.ts"; import { storeStreamingSemanticCacheResponse } from "./chatCore/streamingSemanticCacheStore.ts"; +import { assembleStreamingPipeline } from "./chatCore/streamingPipeline.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; import { getHeaderValueCaseInsensitive, @@ -71,9 +72,10 @@ import { } from "../utils/stream.ts"; import { ensureStreamReadiness } from "../utils/streamReadiness.ts"; import { resolveStreamReadinessTimeout } from "../utils/streamReadinessPolicy.ts"; -import { createStreamController, pipeWithDisconnect } from "../utils/streamHandler.ts"; +import { createStreamController } from "../utils/streamHandler.ts"; import * as streamFailure from "../utils/streamFailureFinalization.ts"; import { createSseHeartbeatTransform, shapeForClientFormat } from "../utils/sseHeartbeat.ts"; +import { addBufferToUsage, filterUsageForFormat, estimateUsage } from "../utils/usageTracking.ts"; import { refreshWithRetry, isUnrecoverableRefreshError, @@ -91,7 +93,7 @@ import { import { resolveModelAlias } from "../services/modelDeprecation.ts"; import { normalizeMimoThinking } from "../services/mimoThinking.ts"; import { normalizeClaudeAdaptiveThinking } from "../services/claudeAdaptiveThinking.ts"; -import { echoModelInObject, createModelEchoTransform } from "../services/responseModelEcho.ts"; +import { echoModelInObject } from "../services/responseModelEcho.ts"; import { stripGpt5SamplingWhenReasoning } from "../services/gpt5SamplingGuard.ts"; import { getUnsupportedParams } from "../config/providerRegistry.ts"; import { supportsMaxTokens } from "@/lib/modelCapabilities.ts"; @@ -112,7 +114,6 @@ import { HTTP_STATUS, FETCH_BODY_TIMEOUT_MS, PROVIDER_MAX_TOKENS, - SSE_HEARTBEAT_INTERVAL_MS, STREAM_IDLE_TIMEOUT_MS, STREAM_READINESS_TIMEOUT_MS, ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE, @@ -247,9 +248,6 @@ import { isCacheableForWrite, } from "@/lib/semanticCache"; import { saveIdempotency } from "@/lib/idempotencyLayer"; -import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts"; -import { createPiiSseTransform } from "@/lib/streamingPiiTransform"; -import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { isModelUnavailableError, getNextFamilyFallback, @@ -3943,36 +3941,16 @@ export async function handleChatCore({ ); } - // ── Phase 9.3: Progress tracking (opt-in) ── - const progressEnabled = wantsProgress(clientRawRequest?.headers); - let finalStream; - - let piiStream = pipeWithDisconnect(providerResponse, transformStream, streamController); - if (typeof createPiiTransform === "function") { - piiStream = piiStream.pipeThrough((createPiiTransform as () => TransformStream)()); - } else if (isFeatureFlagEnabled("PII_RESPONSE_SANITIZATION")) { - piiStream = piiStream.pipeThrough(createPiiSseTransform()); - } - - if (progressEnabled) { - const progressTransform = createProgressTransform({ signal: streamController.signal }); - // Chain: provider → transform → progress → client - finalStream = piiStream.pipeThrough(progressTransform); - responseHeaders[OMNIROUTE_RESPONSE_HEADERS.progress] = "enabled"; - } else { - finalStream = piiStream; - } - finalStream = finalStream.pipeThrough( - createSseHeartbeatTransform({ - signal: streamController.signal, - intervalMs: SSE_HEARTBEAT_INTERVAL_MS, - shape: shapeForClientFormat(clientResponseFormat), - }) - ); - // #1311: echo the requested alias/combo name in each streamed SSE chunk's model field. - if (echoModel) { - finalStream = finalStream.pipeThrough(createModelEchoTransform(echoModel)); - } + const finalStream = assembleStreamingPipeline({ + providerResponse, + transformStream, + streamController, + createPiiTransform, + clientRawRequestHeaders: clientRawRequest?.headers, + clientResponseFormat, + echoModel, + responseHeaders, + }); // ── Gamification event (fire-and-forget) ── await emitRequestGamificationEvent({ apiKeyId: apiKeyInfo?.id, model, provider }); diff --git a/open-sse/handlers/chatCore/streamingPipeline.ts b/open-sse/handlers/chatCore/streamingPipeline.ts new file mode 100644 index 000000000000..d67803918b14 --- /dev/null +++ b/open-sse/handlers/chatCore/streamingPipeline.ts @@ -0,0 +1,99 @@ +/** + * chatCore streaming response pipeline assembly (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's streaming success path: chain the response transforms onto the + * provider stream — disconnect-aware pipe → PII sanitization (explicit transform or feature-flagged + * SSE transform) → optional progress tracking (Phase 9.3) → SSE heartbeat → optional model-echo + * (#1311). Returns the assembled `finalStream`; mutates the passed `responseHeaders` to add the + * progress marker when progress is enabled. Behaviour is byte-identical to the previous inline + * block, including the exact transform order and branch conditions. + */ +import { pipeWithDisconnect as defaultPipeWithDisconnect } from "../../utils/streamHandler.ts"; +import { + createSseHeartbeatTransform as defaultHeartbeat, + shapeForClientFormat as defaultShape, +} from "../../utils/sseHeartbeat.ts"; +import { createModelEchoTransform as defaultModelEcho } from "../../services/responseModelEcho.ts"; +import { + createProgressTransform as defaultProgress, + wantsProgress as defaultWantsProgress, +} from "../../utils/progressTracker.ts"; +import { createPiiSseTransform as defaultPiiSse } from "@/lib/streamingPiiTransform"; +import { isFeatureFlagEnabled as defaultFeatureFlag } from "@/shared/utils/featureFlags"; +import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; +import { SSE_HEARTBEAT_INTERVAL_MS } from "../../config/constants.ts"; + +type HeadersLike = Headers | Record | null | undefined; + +export interface StreamingPipelineDeps { + wantsProgress: typeof defaultWantsProgress; + pipeWithDisconnect: typeof defaultPipeWithDisconnect; + isFeatureFlagEnabled: typeof defaultFeatureFlag; + createPiiSseTransform: typeof defaultPiiSse; + createProgressTransform: typeof defaultProgress; + createSseHeartbeatTransform: typeof defaultHeartbeat; + shapeForClientFormat: typeof defaultShape; + createModelEchoTransform: typeof defaultModelEcho; +} + +const DEFAULT_DEPS: StreamingPipelineDeps = { + wantsProgress: defaultWantsProgress, + pipeWithDisconnect: defaultPipeWithDisconnect, + isFeatureFlagEnabled: defaultFeatureFlag, + createPiiSseTransform: defaultPiiSse, + createProgressTransform: defaultProgress, + createSseHeartbeatTransform: defaultHeartbeat, + shapeForClientFormat: defaultShape, + createModelEchoTransform: defaultModelEcho, +}; + +export function assembleStreamingPipeline( + args: { + providerResponse: unknown; + transformStream: unknown; + streamController: { signal: AbortSignal }; + createPiiTransform: unknown; + clientRawRequestHeaders: HeadersLike; + clientResponseFormat: unknown; + echoModel: string | null | undefined; + responseHeaders: Record; + }, + deps: StreamingPipelineDeps = DEFAULT_DEPS +) { + // ── Phase 9.3: Progress tracking (opt-in) ── + const progressEnabled = deps.wantsProgress(args.clientRawRequestHeaders); + let finalStream; + + let piiStream = deps.pipeWithDisconnect( + args.providerResponse, + args.transformStream, + args.streamController + ); + if (typeof args.createPiiTransform === "function") { + piiStream = piiStream.pipeThrough((args.createPiiTransform as () => TransformStream)()); + } else if (deps.isFeatureFlagEnabled("PII_RESPONSE_SANITIZATION")) { + piiStream = piiStream.pipeThrough(deps.createPiiSseTransform()); + } + + if (progressEnabled) { + const progressTransform = deps.createProgressTransform({ signal: args.streamController.signal }); + // Chain: provider → transform → progress → client + finalStream = piiStream.pipeThrough(progressTransform); + args.responseHeaders[OMNIROUTE_RESPONSE_HEADERS.progress] = "enabled"; + } else { + finalStream = piiStream; + } + finalStream = finalStream.pipeThrough( + deps.createSseHeartbeatTransform({ + signal: args.streamController.signal, + intervalMs: SSE_HEARTBEAT_INTERVAL_MS, + shape: deps.shapeForClientFormat(args.clientResponseFormat), + }) + ); + // #1311: echo the requested alias/combo name in each streamed SSE chunk's model field. + if (args.echoModel) { + finalStream = finalStream.pipeThrough(deps.createModelEchoTransform(args.echoModel)); + } + return finalStream; +} diff --git a/tests/unit/chatcore-streaming-pipeline.test.ts b/tests/unit/chatcore-streaming-pipeline.test.ts new file mode 100644 index 000000000000..c112ff6d217c --- /dev/null +++ b/tests/unit/chatcore-streaming-pipeline.test.ts @@ -0,0 +1,102 @@ +// Characterization of assembleStreamingPipeline — the streaming transform-chain assembly extracted +// from handleChatCore's streaming success path (chatCore god-file decomposition, #3501). All +// transform factories are injected; a fake "stream" records each pipeThrough so the exact chain +// order and the branch conditions (PII explicit vs feature-flag, progress, echo) are observable +// without real ReadableStreams. Locks: transform order, the progress header side-effect, and the +// PII branch precedence (explicit createPiiTransform wins over the feature flag). +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { assembleStreamingPipeline } = await import( + "../../open-sse/handlers/chatCore/streamingPipeline.ts" +); + +// A fake stream: each pipeThrough appends the transform's tag and returns a new fake stream. +function fakeStream(tag: string, log: string[]) { + return { + tag, + pipeThrough(t: { __tag: string }) { + log.push(t.__tag); + return fakeStream(t.__tag, log); + }, + }; +} + +function makeDeps(over: Record = {}) { + const log: string[] = []; + const deps = { + wantsProgress: () => false, + pipeWithDisconnect: (..._a: unknown[]) => fakeStream("pii-base", log), + isFeatureFlagEnabled: () => false, + createPiiSseTransform: () => ({ __tag: "pii-flag" }), + createProgressTransform: () => ({ __tag: "progress" }), + createSseHeartbeatTransform: () => ({ __tag: "heartbeat" }), + shapeForClientFormat: (f: unknown) => f, + createModelEchoTransform: () => ({ __tag: "echo" }), + ...over, + } as Parameters[1]; + return { deps, log }; +} + +function baseArgs(over: Record = {}) { + return { + providerResponse: {}, + transformStream: {}, + streamController: { signal: {} as AbortSignal }, + createPiiTransform: undefined, + clientRawRequestHeaders: null, + clientResponseFormat: "openai", + echoModel: null, + responseHeaders: {} as Record, + ...over, + } as Parameters[0]; +} + +test("baseline (no pii, no progress, no echo) → only heartbeat in the chain", () => { + const { deps, log } = makeDeps(); + assembleStreamingPipeline(baseArgs(), deps); + assert.deepEqual(log, ["heartbeat"]); +}); + +test("feature-flag PII → pii-flag transform applied before heartbeat", () => { + const { deps, log } = makeDeps({ isFeatureFlagEnabled: () => true }); + assembleStreamingPipeline(baseArgs(), deps); + assert.deepEqual(log, ["pii-flag", "heartbeat"]); +}); + +test("explicit createPiiTransform wins over the feature flag", () => { + const { deps, log } = makeDeps({ isFeatureFlagEnabled: () => true }); + const explicit = () => ({ __tag: "pii-explicit" }); + assembleStreamingPipeline(baseArgs({ createPiiTransform: explicit }), deps); + assert.deepEqual(log, ["pii-explicit", "heartbeat"]); +}); + +test("progress enabled → progress transform + progress header set", () => { + const { deps, log } = makeDeps({ wantsProgress: () => true }); + const args = baseArgs(); + assembleStreamingPipeline(args, deps); + assert.deepEqual(log, ["progress", "heartbeat"]); + assert.ok(Object.values(args.responseHeaders).includes("enabled")); +}); + +test("progress disabled → no progress header", () => { + const { deps } = makeDeps({ wantsProgress: () => false }); + const args = baseArgs(); + assembleStreamingPipeline(args, deps); + assert.deepEqual(args.responseHeaders, {}); +}); + +test("echoModel set → echo transform applied last", () => { + const { deps, log } = makeDeps(); + assembleStreamingPipeline(baseArgs({ echoModel: "alias-x" }), deps); + assert.deepEqual(log, ["heartbeat", "echo"]); +}); + +test("full chain order: pii → progress → heartbeat → echo", () => { + const { deps, log } = makeDeps({ + isFeatureFlagEnabled: () => true, + wantsProgress: () => true, + }); + assembleStreamingPipeline(baseArgs({ echoModel: "alias-x" }), deps); + assert.deepEqual(log, ["pii-flag", "progress", "heartbeat", "echo"]); +}); From 7f5b1e4d8367bd02a80f44905487347db8f9d9e0 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:16:23 -0300 Subject: [PATCH 015/120] =?UTF-8?q?ci(quality):=20shift=20heavy=20validati?= =?UTF-8?q?ons=20to=20the=20PR=E2=86=92release=20fast-path=20(release-acce?= =?UTF-8?q?leration)=20(#4857)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(quality): add check:test-runner-api gate (vitest-only dirs must use vitest API) * feat(release): reusable CHANGELOG i18n-mirror sync script * chore(ops): add prune-stale-worktrees.sh (dry-run by default) * ci(quality): run test-runner-api + docs-all + vitest + full unit suite on PR->release fast-path --------- Co-authored-by: Diego Rodrigues de Sa e Souza --- .github/workflows/quality.yml | 48 +++++++++++++++ config/quality/quality-baseline.json | 3 +- package.json | 2 + scripts/check/check-test-runner-api.mjs | 59 +++++++++++++++++++ scripts/ops/prune-stale-worktrees.sh | 31 ++++++++++ scripts/release/sync-changelog-i18n.mjs | 57 ++++++++++++++++++ .../unit/build/check-test-runner-api.test.ts | 35 +++++++++++ tests/unit/build/sync-changelog-i18n.test.ts | 47 +++++++++++++++ 8 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 scripts/check/check-test-runner-api.mjs create mode 100755 scripts/ops/prune-stale-worktrees.sh create mode 100644 scripts/release/sync-changelog-i18n.mjs create mode 100644 tests/unit/build/check-test-runner-api.test.ts create mode 100644 tests/unit/build/sync-changelog-i18n.test.ts diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index b6cfcb2e8e8b..1c3fdc5e36d4 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -40,6 +40,8 @@ jobs: - run: npm run check:fetch-targets - run: npm run check:openapi-routes - run: npm run check:docs-symbols + - name: Docs accuracy (fabricated-docs + i18n mirrors, strict) + run: npm run check:docs-all - run: npm run check:deps - run: npm run check:file-size - run: npm run check:error-helper @@ -49,6 +51,7 @@ jobs: - run: npm run check:known-symbols - run: npm run check:route-guard-membership - run: npm run check:test-discovery + - run: npm run check:test-runner-api - run: npm run check:any-budget:t11 - name: Typecheck (core) run: npm run typecheck:core @@ -82,3 +85,48 @@ jobs: echo "Running impacted tests:"; echo "$SEL" mapfile -t FILES <<< "$SEL" node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${FILES[@]}" + + fast-vitest: + name: Vitest (fast-path) + runs-on: ubuntu-latest + env: + JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-lint-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - run: npm ci + - run: npm run test:vitest + + fast-unit: + name: Unit Tests fast-path (${{ matrix.shard }}/2) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2] + env: + JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-lint-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - run: npm ci + - run: > + node --max-old-space-size=4096 --import tsx + --import ./tests/_setup/isolateDataDir.ts + --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 + tests/unit/*.test.ts + "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts" diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index d4e029c2b288..723c6f6b75e9 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -122,9 +122,10 @@ "dedicatedGate": true }, "zizmorFindings": { - "value": 155, + "value": 159, "direction": "down", "dedicatedGate": true, + "_rebaseline_2026_06_23_fastpath_gates": "155 -> 159 (+4). Two new jobs added to .github/workflows/quality.yml (fast-vitest, fast-unit) to run vitest + the full unit suite on the PR->release fast-path (release-acceleration plan, _tasks/release-bench/v3.8.35/PLANO-IMPLEMENTACAO.md). The +4 are unpinned-uses: actions/checkout@v7 + actions/setup-node@v6 in each of the 2 jobs — the SAME deliberate @vN convention as every other workflow (see _scanner_harden_workflows_2026_06_16). SHA-pinning only these would violate the convention. No new template-injection/artipacked/cache-poisoning. Measured locally via `npm run check:workflows -- --ratchet` = 159.", "_rebaseline_2026_06_23_v3834_release": "152 -> 155 (+3). The 3 new unpinned-uses are in .github/workflows/nightly-release-green.yml (added by #4622 this cycle): actions/checkout@v7, actions/setup-node@v6, actions/upload-artifact@v4 — the SAME deliberate @vN convention as ci.yml's own checkout@v7/setup-node@v6 and every other workflow (see _scanner_harden_workflows_2026_06_16 + _zizmor_rebaseline_2026_06_20_ci_build_artifact_reuse). SHA-pinning only this workflow would violate the convention. The workflow-lint ratchet does NOT run on PR->release fast-gates, so it surfaced only on the release PR; measured locally via `npm run check:workflows -- --ratchet` = 155. No new template-injection/artipacked/cache-poisoning." }, "vulnCount": { diff --git a/package.json b/package.json index 1517eabfa5bf..a9960fd77443 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "gen:provider-reference": "node --import tsx scripts/docs/gen-provider-reference.ts", "bench:compression": "node --import tsx scripts/compression/benchmark.ts", "eval:compression": "node --import tsx scripts/compression-eval/index.ts", + "release:sync-changelog-i18n": "node scripts/release/sync-changelog-i18n.mjs", "build": "node scripts/build/build-next-isolated.mjs", "build:secure": "OMNIROUTE_BUILD_PROFILE=minimal node scripts/build/build-next-isolated.mjs", "build:cli": "node --import tsx scripts/build/prepublish.ts", @@ -135,6 +136,7 @@ "check:duplication": "node scripts/check/check-duplication.mjs", "check:tracked-artifacts": "node scripts/check/check-tracked-artifacts.mjs", "check:test-masking": "node scripts/check/check-test-masking.mjs", + "check:test-runner-api": "node scripts/check/check-test-runner-api.mjs", "check:error-helper": "node scripts/check/check-error-helper.mjs", "check:migration-numbering": "node scripts/check/check-migration-numbering.mjs", "check:public-creds": "node scripts/check/check-public-creds.mjs", diff --git a/scripts/check/check-test-runner-api.mjs b/scripts/check/check-test-runner-api.mjs new file mode 100644 index 000000000000..f99eda9cc42b --- /dev/null +++ b/scripts/check/check-test-runner-api.mjs @@ -0,0 +1,59 @@ +import fs from "node:fs"; +import path from "node:path"; + +// Dirs collected ONLY by vitest (vitest.mcp.config.ts include globs for .ts tests). +// Keep in sync with vitest.mcp.config.ts. A test here MUST import from "vitest". +const VITEST_ONLY_DIRS = [ + "tests/unit/autoCombo", + "open-sse/services/autoCombo", + "open-sse/mcp-server", +]; + +function walk(dir, root, out = []) { + const abs = path.join(root, dir); + if (!fs.existsSync(abs)) return out; + for (const name of fs.readdirSync(abs)) { + const rel = path.join(dir, name); + const s = fs.statSync(path.join(root, rel)); + if (s.isDirectory()) { + if (name === "node_modules" || name === ".git") continue; + walk(rel, root, out); + } else if (/\.test\.(ts|tsx|js|mjs)$/.test(name)) { + out.push(rel); + } + } + return out; +} + +function isVitestOnly(relFile) { + const norm = relFile.replace(/\\/g, "/"); + return VITEST_ONLY_DIRS.some( + (d) => norm.startsWith(d + "/") && (norm.includes("/__tests__/") || d.startsWith("tests/unit/")) + ); +} + +export function findRunnerMismatches(root) { + const files = VITEST_ONLY_DIRS.flatMap((d) => walk(d, root)); + const bad = []; + for (const f of files) { + if (!isVitestOnly(f)) continue; + const txt = fs.readFileSync(path.join(root, f), "utf8"); + const importsNodeTest = /from\s+["']node:test["']/.test(txt); + const importsVitest = /from\s+["']vitest["']/.test(txt); + if (importsNodeTest && !importsVitest) { + bad.push({ file: f, reason: "vitest-only dir but imports node:test (use the vitest API)" }); + } + } + return bad; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const root = process.cwd(); + const bad = findRunnerMismatches(root); + if (bad.length) { + console.error(`[test-runner-api] FAIL — ${bad.length} test(s) use the wrong runner API:`); + for (const b of bad) console.error(` ${b.file}: ${b.reason}`); + process.exit(1); + } + console.log("[test-runner-api] OK — vitest-only dirs use the vitest API."); +} diff --git a/scripts/ops/prune-stale-worktrees.sh b/scripts/ops/prune-stale-worktrees.sh new file mode 100755 index 000000000000..8a8a30f716cb --- /dev/null +++ b/scripts/ops/prune-stale-worktrees.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Lista (ou remove com --apply) worktrees port-* cuja branch já foi merged ao +# main OU não tem commits à frente do origin/main. NUNCA toca worktrees com +# trabalho não-commitado. Rodar a partir do checkout principal. +set -euo pipefail +APPLY="${1:-}" +git fetch origin main --quiet || true +git worktree list --porcelain | awk '/^worktree /{w=$2} /^branch /{print w" "$2}' | \ +while read -r dir ref; do + case "$dir" in + *"/.claude/worktrees/"*port-*|*"/.worktrees/"*port-*) ;; + *) continue ;; + esac + br="${ref#refs/heads/}" + if [ -n "$(git -C "$dir" status --porcelain 2>/dev/null)" ]; then + echo "SKIP (dirty): $dir" + continue + fi + ahead=$(git -C "$dir" rev-list --count origin/main.."$br" 2>/dev/null || echo "?") + if [ "$ahead" = "0" ]; then + if [ "$APPLY" = "--apply" ]; then + git worktree remove --force "$dir" && git branch -D "$br" 2>/dev/null || true + echo "REMOVED: $dir ($br)" + else + echo "WOULD REMOVE (0 ahead of main): $dir ($br)" + fi + else + echo "KEEP ($ahead ahead): $dir ($br)" + fi +done +echo "Done.${APPLY:+ (applied)}" diff --git a/scripts/release/sync-changelog-i18n.mjs b/scripts/release/sync-changelog-i18n.mjs new file mode 100644 index 000000000000..9fea0daa792b --- /dev/null +++ b/scripts/release/sync-changelog-i18n.mjs @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import path from "node:path"; + +function sectionRange(text, ver, nextVer) { + const lines = text.split("\n"); + const start = lines.findIndex((l) => l.startsWith(`## [${ver}]`)); + if (start < 0) return null; + let end = lines.findIndex((l, i) => i > start && l.startsWith(`## [${nextVer}]`)); + if (end < 0) end = lines.length; + return { lines, start, end }; +} + +// Replace the [ver] section in every docs/i18n//CHANGELOG.md with the root one. +// If a mirror lacks the section, insert it before [nextVer]. Returns count updated. +export function syncChangelogSection(root, ver, nextVer) { + const rootCl = fs.readFileSync(path.join(root, "CHANGELOG.md"), "utf8"); + const r = sectionRange(rootCl, ver, nextVer); + if (!r) throw new Error(`root CHANGELOG missing [${ver}]`); + const block = r.lines.slice(r.start, r.end).join("\n"); + const i18nDir = path.join(root, "docs/i18n"); + if (!fs.existsSync(i18nDir)) return 0; + let n = 0; + for (const loc of fs.readdirSync(i18nDir)) { + const fp = path.join(i18nDir, loc, "CHANGELOG.md"); + if (!fs.existsSync(fp)) continue; + const txt = fs.readFileSync(fp, "utf8"); + const m = sectionRange(txt, ver, nextVer); + let next; + if (m) { + next = + m.lines.slice(0, m.start).join("\n") + + "\n" + + block + + "\n" + + m.lines.slice(m.end).join("\n"); + } else { + const idx = txt.indexOf(`## [${nextVer}]`); + if (idx < 0) continue; + next = txt.slice(0, idx) + block + "\n\n" + txt.slice(idx); + } + if (next !== txt) { + fs.writeFileSync(fp, next); + n++; + } + } + return n; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const [ver, nextVer] = process.argv.slice(2); + if (!ver || !nextVer) { + console.error("usage: sync-changelog-i18n.mjs "); + process.exit(2); + } + const n = syncChangelogSection(process.cwd(), ver, nextVer); + console.log(`[sync-changelog-i18n] updated ${n} mirror(s) for [${ver}]`); +} diff --git a/tests/unit/build/check-test-runner-api.test.ts b/tests/unit/build/check-test-runner-api.test.ts new file mode 100644 index 000000000000..8de6f9ffde90 --- /dev/null +++ b/tests/unit/build/check-test-runner-api.test.ts @@ -0,0 +1,35 @@ +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"; +import { findRunnerMismatches } from "../../../scripts/check/check-test-runner-api.mjs"; + +function tmpRepo() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runner-api-")); + fs.mkdirSync(path.join(root, "tests/unit/autoCombo"), { recursive: true }); + return root; +} + +test("flags a vitest-only-dir test that imports node:test", () => { + const root = tmpRepo(); + fs.writeFileSync( + path.join(root, "tests/unit/autoCombo/bad.test.ts"), + `import { describe, it } from "node:test";\ndescribe("x", () => it("y", () => {}));\n` + ); + const bad = findRunnerMismatches(root); + assert.equal(bad.length, 1); + assert.match(bad[0].file, /autoCombo\/bad\.test\.ts$/); + assert.match(bad[0].reason, /vitest-only/); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("accepts a vitest-only-dir test that imports vitest", () => { + const root = tmpRepo(); + fs.writeFileSync( + path.join(root, "tests/unit/autoCombo/good.test.ts"), + `import { describe, it } from "vitest";\ndescribe("x", () => it("y", () => {}));\n` + ); + assert.equal(findRunnerMismatches(root).length, 0); + fs.rmSync(root, { recursive: true, force: true }); +}); diff --git a/tests/unit/build/sync-changelog-i18n.test.ts b/tests/unit/build/sync-changelog-i18n.test.ts new file mode 100644 index 000000000000..14eb8247b0a9 --- /dev/null +++ b/tests/unit/build/sync-changelog-i18n.test.ts @@ -0,0 +1,47 @@ +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"; +import { syncChangelogSection } from "../../../scripts/release/sync-changelog-i18n.mjs"; + +function repo() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cl-i18n-")); + fs.writeFileSync( + path.join(root, "CHANGELOG.md"), + "# Changelog\n\n## [Unreleased]\n\n---\n\n## [9.9.9] — 2026-01-01\n\n- **feat:** big new thing\n- **fix:** another\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n" + ); + for (const loc of ["fr", "de"]) { + fs.mkdirSync(path.join(root, "docs/i18n", loc), { recursive: true }); + fs.writeFileSync( + path.join(root, "docs/i18n", loc, "CHANGELOG.md"), + "# Changelog\n\n---\n\n## [9.9.9] — TBD\n\n_stub_\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n" + ); + } + return root; +} + +test("replaces the version section in every mirror with the root section", () => { + const root = repo(); + const n = syncChangelogSection(root, "9.9.9", "9.9.8"); + assert.equal(n, 2); + const fr = fs.readFileSync(path.join(root, "docs/i18n/fr/CHANGELOG.md"), "utf8"); + assert.match(fr, /big new thing/); + assert.doesNotMatch(fr, /_stub_/); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("inserts the section when a mirror lacks it", () => { + const root = repo(); + // remove the 9.9.9 section from the fr mirror entirely + fs.writeFileSync( + path.join(root, "docs/i18n/fr/CHANGELOG.md"), + "# Changelog\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n" + ); + const n = syncChangelogSection(root, "9.9.9", "9.9.8"); + assert.equal(n, 2); + const fr = fs.readFileSync(path.join(root, "docs/i18n/fr/CHANGELOG.md"), "utf8"); + assert.match(fr, /## \[9\.9\.9\]/); + assert.match(fr, /big new thing/); + fs.rmSync(root, { recursive: true, force: true }); +}); From c922b21102c8069cbdb1eb1ad6e3954e696b5c5b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:17:09 -0300 Subject: [PATCH 016/120] =?UTF-8?q?fix(quota):=20cota=20exclusiva=20lista?= =?UTF-8?q?=20qtSd/=20no=20/v1/models=20(#4806)=20+=20limite=20EPSILON=20n?= =?UTF-8?q?=C3=A3o=20bloqueia=20(#4830)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — quota-exclusive qtSd/ listing (#4806) + EPSILON placeholder no longer blocks; rebuilt from stale base (3 defining commits cherry-picked clean over release tip) --- src/app/api/v1/models/catalog.ts | 17 +- src/lib/quota/enforce.ts | 22 ++- src/lib/quota/quotaCombos.ts | 85 +++++++- .../quota-epsilon-unconfigured-allow.test.ts | 98 ++++++++++ .../unit/quota-exclusive-catalog-4806.test.ts | 182 ++++++++++++++++++ 5 files changed, 384 insertions(+), 20 deletions(-) create mode 100644 tests/unit/quota-epsilon-unconfigured-allow.test.ts create mode 100644 tests/unit/quota-exclusive-catalog-4806.test.ts diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 21df30ac10df..aabacf77fa75 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -1370,15 +1370,18 @@ export async function getUnifiedModelsResponse( if (apiKey) { const { isModelAllowedForKey, getApiKeyMetadata } = await import("@/lib/db/apiKeys"); - // Quota-exclusive keys (allowedQuotas non-empty): show only the - // quotaShared-* virtual models for the key's assigned pools (Phase B3). - // This takes precedence over the normal allowedModels filter. + // Quota-exclusive keys (allowedQuotas non-empty): list ONLY the pool's qtSd/* + // virtual models. #4806: build from the hidden qtSd/* combos directly — the base + // `models` list drops hidden combos, so filtering it returned nothing (0 models). const keyMeta = await getApiKeyMetadata(apiKey); if (keyMeta && keyMeta.allowedQuotas && keyMeta.allowedQuotas.length > 0) { - const { resolveQuotaKeyScope } = await import("@/lib/quota/quotaKey"); - const { filterModelsToQuotaPools } = await import("@/lib/quota/quotaCombos"); - const scope = await resolveQuotaKeyScope(keyMeta.allowedQuotas); - finalModels = filterModelsToQuotaPools(models, scope.poolSlugs); + const { buildQuotaExclusiveModels } = await import("@/lib/quota/quotaCombos"); + finalModels = await buildQuotaExclusiveModels( + keyMeta.allowedQuotas, + combos, + timestamp, + (c) => buildComboCatalogMetadata(c, combos) + ); } else { const filtered = []; for (const m of models) { diff --git a/src/lib/quota/enforce.ts b/src/lib/quota/enforce.ts index 404538e60dbc..29a81acbd38f 100644 --- a/src/lib/quota/enforce.ts +++ b/src/lib/quota/enforce.ts @@ -60,7 +60,10 @@ const COUNTABLE_UNITS = new Set(["requests", "tokens", "usd"]); */ export async function enforceQuotaShare(input: EnforceInput): Promise { // 1. Find pools that contain this apiKeyId. - let allocations: Array<{ poolId: string; allocation: import("@/lib/db/quotaPools").PoolAllocation }>; + let allocations: Array<{ + poolId: string; + allocation: import("@/lib/db/quotaPools").PoolAllocation; + }>; try { allocations = listAllocationsForApiKey(input.apiKeyId); } catch { @@ -129,6 +132,13 @@ export async function enforceQuotaShare(input: EnforceInput): Promise = {}; for (const dim of plan.dimensions) { + // Skip placeholder dimensions whose limit is unknown (Number.EPSILON / 0 — the + // "configure manually" seed for glm/minimax/kimi-coding/deepseek in planRegistry.ts). + // An unconfigured limit is NOT a real cap: enforcing it would block every request + // after the first one, because decideFairShare's global-saturated gate fires as soon + // as consumedTotal exceeds ~EPSILON. The real cap kicks in once a limit is set via + // the Wizard "Limite" step (provider_plans override). + if (!(dim.limit > Number.EPSILON)) continue; const dimKey = { poolId: pool.id, unit: dim.unit, window: dim.window }; const dimKeyStr = dimensionKeyToString(dimKey); @@ -224,7 +234,10 @@ export async function enforceQuotaShare(input: EnforceInput): Promise { - let allocations: Array<{ poolId: string; allocation: import("@/lib/db/quotaPools").PoolAllocation }>; + let allocations: Array<{ + poolId: string; + allocation: import("@/lib/db/quotaPools").PoolAllocation; + }>; try { allocations = listAllocationsForApiKey(input.apiKeyId); } catch { @@ -288,10 +301,7 @@ function messageForReason(reason: string, provider: string): string { } } -function costForUnit( - cost: RecordConsumptionInput["cost"], - unit: QuotaUnit -): number { +function costForUnit(cost: RecordConsumptionInput["cost"], unit: QuotaUnit): number { switch (unit) { case "tokens": return cost.tokens ?? 0; diff --git a/src/lib/quota/quotaCombos.ts b/src/lib/quota/quotaCombos.ts index ed625cf8f974..b11df536afe2 100644 --- a/src/lib/quota/quotaCombos.ts +++ b/src/lib/quota/quotaCombos.ts @@ -21,7 +21,12 @@ import { updateCombo, } from "@/lib/db/combos"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry"; -import { quotaModelName, parseQuotaModelName, isQuotaModelName, quotaGroupSlug } from "./quotaModelNaming"; +import { + quotaModelName, + parseQuotaModelName, + isQuotaModelName, + quotaGroupSlug, +} from "./quotaModelNaming"; import { createLogger } from "@/shared/utils/logger"; const log = createLogger("quota/quotaCombos"); @@ -87,7 +92,11 @@ function getProviderModelIds(provider: string): string[] { const models = entry && Array.isArray(entry.models) ? entry.models : []; if (models.length === 0) return []; return models - .map((m) => (typeof m === "object" && m !== null && typeof (m as { id?: unknown }).id === "string" ? (m as { id: string }).id : null)) + .map((m) => + typeof m === "object" && m !== null && typeof (m as { id?: unknown }).id === "string" + ? (m as { id: string }).id + : null + ) .filter((id): id is string => id !== null && id.length > 0); } @@ -184,7 +193,12 @@ export async function syncQuotaCombos(poolId: string): Promise { })); try { const existing = await getComboByName(comboName); - const payload = { name: comboName, models: steps, strategy: "fill-first" as const, isHidden: true }; + const payload = { + name: comboName, + models: steps, + strategy: "fill-first" as const, + isHidden: true, + }; if (existing && typeof existing.id === "string") await updateCombo(existing.id, payload); else await createCombo(payload); } catch (err) { @@ -228,7 +242,10 @@ export async function syncQuotaCombos(poolId: string): Promise { try { await deleteComboByName(name); } catch (err) { - log.warn({ err: (err as Error)?.message, comboName: name, poolId }, "quota-combo prune failed"); + log.warn( + { err: (err as Error)?.message, comboName: name, poolId }, + "quota-combo prune failed" + ); } } } @@ -261,6 +278,51 @@ export function filterModelsToQuotaPools( }); } +/** + * #4806 — Build the /v1/models catalog list for a quota-exclusive API key. + * + * The pool's qtSd/// combos are isHidden:true, so the + * base /v1/models list (which skips hidden combos) never contains them. Filtering + * that base list therefore returned nothing and clients (e.g. Claude Desktop) + * showed "0 modelo encontrado" once "Cota exclusiva" was enabled on the pool. + * + * This selects the (hidden) qtSd/* combos whose parsed group slug is in + * `poolSlugs` and maps each through `toEntry` — the catalog's own combo-entry + * builder — so it stays free of catalog-internal helpers and is unit-testable. + * + * Fail-closed: an empty `poolSlugs` array yields an empty list (a quota key with + * no resolvable pools sees no models). + */ +export async function buildQuotaExclusiveModels( + allowedQuotas: string[], + combos: TCombo[], + timestamp: number, + metadataFor: (combo: TCombo) => Record +): Promise>> { + const { resolveQuotaKeyScope } = await import("./quotaKey"); + const scope = await resolveQuotaKeyScope(allowedQuotas); + if (scope.poolSlugs.length === 0) return []; + const slugSet = new Set(scope.poolSlugs); + const out: Array> = []; + for (const combo of combos) { + const name = typeof combo.name === "string" ? combo.name : ""; + if (!isQuotaModelName(name)) continue; + const parsed = parseQuotaModelName(name); + if (!parsed || !slugSet.has(parsed.groupSlug)) continue; + out.push({ + id: name, + object: "model", + created: timestamp, + owned_by: "combo", + permission: [], + root: name, + parent: null, + ...metadataFor(combo), + }); + } + return out; +} + /** * Delete ALL `quotaShared-*` combos that belong to the given pool. * @@ -290,7 +352,10 @@ export async function removeQuotaCombosForPool(poolId: string): Promise { let poolProvider: string | undefined; for (const connId of pool.connectionIds) { try { - const connection = (await getProviderConnectionById(connId)) as Record | null; + const connection = (await getProviderConnectionById(connId)) as Record< + string, + unknown + > | null; if (connection && typeof connection.provider === "string" && connection.provider.length > 0) { poolProvider = connection.provider; break; @@ -309,7 +374,10 @@ export async function removeQuotaCombosForPool(poolId: string): Promise { try { allCombos = await getCombos(); } catch (err) { - log.warn({ err: (err as Error)?.message, poolId }, "removeQuotaCombosForPool: getCombos failed"); + log.warn( + { err: (err as Error)?.message, poolId }, + "removeQuotaCombosForPool: getCombos failed" + ); return; } @@ -328,7 +396,10 @@ export async function removeQuotaCombosForPool(poolId: string): Promise { try { await deleteComboByName(name); } catch (err) { - log.warn({ err: (err as Error)?.message, comboName: name, poolId }, "quota-combo remove failed"); + log.warn( + { err: (err as Error)?.message, comboName: name, poolId }, + "quota-combo remove failed" + ); } } } diff --git a/tests/unit/quota-epsilon-unconfigured-allow.test.ts b/tests/unit/quota-epsilon-unconfigured-allow.test.ts new file mode 100644 index 000000000000..1d93c6053efd --- /dev/null +++ b/tests/unit/quota-epsilon-unconfigured-allow.test.ts @@ -0,0 +1,98 @@ +/** + * tests/unit/quota-epsilon-unconfigured-allow.test.ts + * + * Bug: a quota pool whose provider plan carries a PLACEHOLDER limit + * (Number.EPSILON — glm / minimax / kimi-coding / deepseek in planRegistry.ts, + * meaning "limit unknown, set it manually in the Wizard 'Limite' step") blocks + * every request AFTER the first one. + * + * decideFairShare's global-saturated gate fires as soon as + * consumedTotal >= effectiveLimit + * and effectiveLimit ≈ EPSILON, so ANY recorded consumption trips it — for hard, + * soft AND burst policies. planRegistry.ts documents the intent ("Sliding window / + * fair-share devem tratar limit=0 como 'manual obrigatório'") but enforce.ts never + * implemented the guard. + * + * Expected: an unconfigured (EPSILON) dimension is NOT enforced — the pool stays + * usable until a real limit is configured. Real limits still enforce normally + * (covered by quota-division-blocks.test.ts). + * + * Mirrors the SqliteQuotaStore + enforceQuotaShare integration setup from + * quota-division-blocks.test.ts. + */ + +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-quota-epsilon-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const providersDb = await import("../../src/lib/db/providers.ts"); +const quotaPools = await import("../../src/lib/db/quotaPools.ts"); +const { SqliteQuotaStore } = await import("../../src/lib/quota/sqliteQuotaStore.ts"); +const { enforceQuotaShare } = await import("../../src/lib/quota/enforce.ts"); +const core = await import("../../src/lib/db/core.ts"); + +test.after(() => { + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } +}); + +test("EPSILON (unconfigured) plan does not block after the first request", async () => { + const KEY = "key-eps"; + + // glm seeds tokens/5h + tokens/weekly with limit=Number.EPSILON (placeholder). + const conn = await providersDb.createProviderConnection({ + provider: "glm", + authType: "apikey", + name: "quota-epsilon-glm", + apiKey: "sk-glm-epsilon", + }); + const connId = (conn as Record).id as string; + assert.ok(connId, "connection should have an id"); + + const pool = quotaPools.createPool({ + connectionId: connId, + name: "EpsilonPool", + allocations: [{ apiKeyId: KEY, weight: 100, policy: "hard" }], + }); + + // First request: allowed (nothing consumed yet). + const first = await enforceQuotaShare({ + apiKeyId: KEY, + connectionId: connId, + provider: "glm", + estimatedCost: { tokens: 5000 }, + }); + assert.equal( + first.kind, + "allow", + `first request should be allowed; got ${JSON.stringify(first)}` + ); + + // Record consumption on the EPSILON dimension (tokens/5h). + const store = new SqliteQuotaStore(); + await store.consume(KEY, { poolId: pool.id, unit: "tokens", window: "5h" }, 5000); + + // Second request: MUST still be allowed — an unconfigured EPSILON limit is not a real cap. + const second = await enforceQuotaShare({ + apiKeyId: KEY, + connectionId: connId, + provider: "glm", + estimatedCost: { tokens: 5000 }, + }); + assert.equal( + second.kind, + "allow", + `unconfigured EPSILON plan must not block after the first request; got ${JSON.stringify(second)}` + ); +}); diff --git a/tests/unit/quota-exclusive-catalog-4806.test.ts b/tests/unit/quota-exclusive-catalog-4806.test.ts new file mode 100644 index 000000000000..75cd30bcece2 --- /dev/null +++ b/tests/unit/quota-exclusive-catalog-4806.test.ts @@ -0,0 +1,182 @@ +/** + * tests/unit/quota-exclusive-catalog-4806.test.ts + * + * Regression for #4806 — "[BUG] Cota Exclusiva: modelos virtuais param de + * listar após ativação". + * + * When an API key is scoped to a quota pool (allowedQuotas non-empty — the + * "Cota exclusiva" state), GET /v1/models must list that pool's + * qtSd/// virtual models. Clients such as Claude + * Desktop build their model picker from /v1/models, so an empty list shows + * "0 modelo encontrado" and the key cannot be used. + * + * Root cause (before the fix): getUnifiedModelsResponse filtered the *base* + * catalog list with filterModelsToQuotaPools(), but the base list never + * contains qtSd/* combos — they are isHidden:true and skipped while the base + * list is built (catalog.ts: `if (combo.isHidden === true) continue;`). The + * filter only KEEPS qtSd/* names, so filtering a list that has none returns + * [] → 0 models for every quota-exclusive key. + * + * Uses "glm" as the provider because it has a small, stable model list in the + * static registry (same convention as quota-combos-sync.test.ts). + */ + +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-quota-exclusive-catalog-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "quota-exclusive-catalog-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const poolsDb = await import("../../src/lib/db/quotaPools.ts"); +const groupsDb = await import("../../src/lib/db/quotaGroups.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +const { syncQuotaCombos } = await import("../../src/lib/quota/quotaCombos.ts"); +const { quotaGroupSlug, isQuotaModelName, parseQuotaModelName } = + await import("../../src/lib/quota/quotaModelNaming.ts"); + +async function resetStorage() { + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException; + if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#4806 quota-exclusive key lists its qtSd/* virtual models in GET /v1/models", async () => { + // Group "Times" → slug "times"; combos will be qtSd/times/glm/. + const group = groupsDb.createGroup("Times"); + + const conn = await providersDb.createProviderConnection({ + provider: "glm", + authType: "apikey", + name: "quota-4806-glm", + apiKey: "sk-glm-4806", + }); + const connId = (conn as Record).id as string; + assert.ok(connId, "connection should have an id"); + + const pool = poolsDb.createPool({ connectionId: connId, name: "Times", groupId: group.id }); + await syncQuotaCombos(pool.id); // mint the hidden qtSd/times/glm/* combos + + // Make the key "quota exclusive" by scoping it to the pool. + const created = await apiKeysDb.createApiKey("Quota-4806 Key", "machine-4806"); + await apiKeysDb.updateApiKeyPermissions(created.id, { allowedQuotas: [pool.id] }); + + const res = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models", { + headers: { Authorization: `Bearer ${created.key}` }, + }) + ); + assert.equal(res.status, 200); + const body = (await res.json()) as { data: Array<{ id: string }> }; + + const slug = quotaGroupSlug("Times"); // "times" + const qtsdForKey = body.data.filter( + (m) => isQuotaModelName(m.id) && parseQuotaModelName(m.id)?.groupSlug === slug + ); + + assert.ok( + qtsdForKey.length > 0, + `quota-exclusive key must see its qtSd/${slug}/glm/* models in /v1/models; ` + + `got ${body.data.length} total, ${qtsdForKey.length} qtSd. ` + + `ids=${JSON.stringify(body.data.map((m) => m.id).slice(0, 8))}` + ); + + // Every returned model must belong to the key's group (no leakage of other pools/raw models). + for (const m of body.data) { + assert.ok( + isQuotaModelName(m.id) && parseQuotaModelName(m.id)?.groupSlug === slug, + `quota-exclusive key should only see its own qtSd/${slug}/* models; leaked: ${m.id}` + ); + } +}); + +test("#4806 quota-exclusive key does NOT see qtSd/* of a group it is not allocated to", async () => { + // Group A (glm) — the key's group. + const groupA = groupsDb.createGroup("Alpha"); + const connA = await providersDb.createProviderConnection({ + provider: "glm", + authType: "apikey", + name: "quota-4806-glm-a", + apiKey: "sk-glm-4806-a", + }); + const poolA = poolsDb.createPool({ + connectionId: (connA as Record).id as string, + name: "Alpha", + groupId: groupA.id, + }); + await syncQuotaCombos(poolA.id); + + // Group B (codex) — a DIFFERENT group the key is NOT allocated to. + const groupB = groupsDb.createGroup("Beta"); + const connB = await providersDb.createProviderConnection({ + provider: "codex", + authType: "apikey", + name: "quota-4806-codex-b", + apiKey: "sk-codex-4806-b", + }); + const poolB = poolsDb.createPool({ + connectionId: (connB as Record).id as string, + name: "Beta", + groupId: groupB.id, + }); + await syncQuotaCombos(poolB.id); + + // Key scoped ONLY to group A's pool. + const created = await apiKeysDb.createApiKey("Quota-4806 Key A", "machine-4806-a"); + await apiKeysDb.updateApiKeyPermissions(created.id, { allowedQuotas: [poolA.id] }); + + const res = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models", { + headers: { Authorization: `Bearer ${created.key}` }, + }) + ); + assert.equal(res.status, 200); + const body = (await res.json()) as { data: Array<{ id: string }> }; + + const slugA = quotaGroupSlug("Alpha"); + const slugB = quotaGroupSlug("Beta"); + + assert.ok( + body.data.some((m) => parseQuotaModelName(m.id)?.groupSlug === slugA), + "key must see its own group A qtSd/* models" + ); + const leakedFromB = body.data.filter((m) => parseQuotaModelName(m.id)?.groupSlug === slugB); + assert.equal( + leakedFromB.length, + 0, + `key in group A must NOT see group B (${slugB}) models; leaked: ${JSON.stringify( + leakedFromB.map((m) => m.id) + )}` + ); +}); From 502f43c283692de6796202fffa5f6af366910be3 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:37:23 -0300 Subject: [PATCH 017/120] feat(sse): add Google Flow video-generation provider (#4569) (#4769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — Google Flow video-generation provider (#4569), release-green validated (typecheck + 21 tests + file-size) --- config/quality/file-size-baseline.json | 3 +- open-sse/config/videoRegistry.ts | 17 ++ open-sse/handlers/videoGeneration.ts | 5 + .../handlers/videoGeneration/googleFlow.ts | 219 ++++++++++++++++++ .../videoGeneration/googleFlowHandler.ts | 145 ++++++++++++ src/app/api/v1/videos/generations/route.ts | 7 +- tests/unit/google-flow-video-4569.test.ts | 214 +++++++++++++++++ 7 files changed, 607 insertions(+), 3 deletions(-) create mode 100644 open-sse/handlers/videoGeneration/googleFlow.ts create mode 100644 open-sse/handlers/videoGeneration/googleFlowHandler.ts create mode 100644 tests/unit/google-flow-video-4569.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 29575e70f93a..1b9400338999 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -81,6 +81,7 @@ "_rebaseline_2026_06_15_3877_byteplus": "Issue #3877 own growth: providerRegistry.ts 4708->4730 (+22 = a byteplus (BytePlus ModelArk) registry entry — OpenAI-compatible, Ark base ap-southeast-1, Bearer, 4 seed models — modeled on the volcengine entry; byteplus was in APIKEY_PROVIDERS but unregistered here so validation returned {unsupported:true}). Cohesive provider registration; not extractable.", "_rebaseline_2026_06_15_3910_nested_combo_ctx": "PR #3910 net +1: providerRegistry.ts 4730->4731 (test-registry contextLength metadata for the nested combo-ref LCD regression test). opencode-plugin-only behavioral change; no core routing/virtualFactory touched.", "_rebaseline_2026_06_15_3929_vertex_media": "PR #3929 own growth: audioSpeech.ts 952->965 (+13) and videoGeneration.ts 1026->1078 (+52) = vertex/* media branches (Gemini TTS, Veo predictLongRunning poll) wired into the speech/video handlers; new logic lives in open-sse/executors/vertexMedia.ts (341, under cap). Cohesive media-provider feature.", + "_rebaseline_2026_06_23_4569_googleflow_video": "Issue #4569 net +5: videoGeneration.ts 1077->1083 = the google-flow dispatch branch + a single handler import. The whole Google Flow handler (145 lines) lives in the new co-located open-sse/handlers/videoGeneration/googleFlowHandler.ts (under cap) and the pure transforms in videoGeneration/googleFlow.ts; only the wiring remains in the god-file. Extracted-not-inlined; not further shrinkable.", "_rebaseline_2026_06_15_3879_redact_thinking": "PR #3879 + #3921 reconcile: AddApiKeyModal.tsx 843->845 (+2 = merging #3879's CcCompatibleRequestDefaultsFields (context1m + opt-in redact-thinking toggle) into #3921's preset-input block in the cc-compatible settings group). Cohesive UI; not extractable.", "_rebaseline_2026_06_15_3890_cache_preserve": "Issue #3890 own growth: chatCore.ts 5815->5823 (+8 = wire resolveCacheAwareConfig() into the compression apply step so the system prompt is never compressed in a caching context — honors the cache-aware skipSystemPrompt flag that selectCompressionStrategy could not carry). Cohesive cache-preservation guard at the existing compression chokepoint; not extractable.", "_rebaseline_2026_06_16_3974_toolsearch_beta": "Issue #3974 own growth: base.ts 1218->1222 (+4 = wrap selectBetaFlags() with mergeClientAnthropicBeta() at the ccHeaders anthropic-beta callsite, plus a 3-line comment, so the client's allowlisted tool-search beta survives). The shared helper + allowlist live in anthropicHeaders.ts (small file, well under cap); default.ts also gains the merge. Cohesive one-callsite fix; not extractable.", @@ -128,7 +129,7 @@ "open-sse/handlers/responseSanitizer.ts": 1103, "open-sse/handlers/search.ts": 1546, "open-sse/handlers/sseParser.ts": 830, - "open-sse/handlers/videoGeneration.ts": 1078, + "open-sse/handlers/videoGeneration.ts": 1083, "open-sse/mcp-server/schemas/tools.ts": 1497, "open-sse/mcp-server/server.ts": 1555, "open-sse/mcp-server/tools/advancedTools.ts": 1118, diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 12d0c8cbc3a3..af1f3652b4d9 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -39,6 +39,23 @@ export const VIDEO_PROVIDERS: Record = { ], }, + googleflow: { + id: "googleflow", + alias: "flow", + // ⚠️ Wire host isolated for live HAR validation (Rule #18). The handler reuses + // the Google account OAuth credential (accessToken + Cloud Code projectId) that + // the Antigravity provider already establishes; no separate OAuth flow is added. + baseUrl: "https://aisandbox-pa.googleapis.com", + authType: "oauth", + authHeader: "bearer", + format: "google-flow", + models: [ + { id: "veo-3.1-generate", name: "Veo 3.1 (Google Flow)" }, + { id: "veo-3.1-fast-generate", name: "Veo 3.1 Fast (Google Flow)" }, + { id: "veo-3.0-generate", name: "Veo 3.0 (Google Flow)" }, + ], + }, + kie: { id: "kie", baseUrl: "https://api.kie.ai", diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 1581133b7fae..d3708fa4be1a 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -18,6 +18,7 @@ import { getVideoProvider, parseVideoModel } from "../config/videoRegistry.ts"; import { kieExecutor } from "../executors/kie.ts"; import { vertexGenerateVideo } from "../executors/vertexMedia.ts"; +import { handleGoogleFlowVideoGeneration } from "./videoGeneration/googleFlowHandler.ts"; import { getExecutor } from "../executors/index.ts"; import { isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; import { @@ -61,6 +62,10 @@ export async function handleVideoGeneration({ body, credentials, log }) { return handleVertexVeoGeneration({ model, body, credentials, log }); } + if (providerConfig.format === "google-flow") { + return handleGoogleFlowVideoGeneration({ model, providerConfig, body, credentials, log }); + } + if (providerConfig.format === "comfyui") { return handleComfyUIVideoGeneration({ model, provider, providerConfig, body, log }); } diff --git a/open-sse/handlers/videoGeneration/googleFlow.ts b/open-sse/handlers/videoGeneration/googleFlow.ts new file mode 100644 index 000000000000..3bef71085795 --- /dev/null +++ b/open-sse/handlers/videoGeneration/googleFlow.ts @@ -0,0 +1,219 @@ +/** + * Google Flow (labs.google/flow) video generation — pure helpers. + * + * Google Flow drives Veo video generation through Google's internal AI Sandbox + * endpoint (`aisandbox-pa.googleapis.com`) using a Google account OAuth bearer + * token — the same Google OAuth credential family that the Antigravity provider + * already uses (accessToken + Cloud Code projectId). The request/response shape + * mirrors the *documented* Veo `predictLongRunning` long-running-operation API + * (see `open-sse/executors/vertexMedia.ts::vertexGenerateVideo`): + * + * submit → { instances: [{ prompt, image? }], parameters: { sampleCount, ... } } → { name: } + * poll → { operationName } → { done, error?, response: { videos: [{ bytesBase64Encoded | gcsUri }] } } + * + * ⚠️ PENDING LIVE VALIDATION (Hard Rule #18): the exact AI-Sandbox URL path and + * whether Flow wraps the Veo body in the Cloud-Code `{ project, request }` envelope + * cannot be unit-tested — they require a real Google Flow account + a captured HAR. + * Everything that depends on the wire host/path is isolated in the two constants + * below and in the handler, so confirming a captured HAR is a one-line change. + * The transformation logic in this file is grounded in the documented Veo shape + * and is fully unit-tested. + */ + +/** + * Google Flow has no standalone connection — it reuses the Antigravity Google + * OAuth credential (accessToken + Cloud Code projectId). Credential lookups for + * the `googleflow` provider resolve against this provider id. + */ +export const GOOGLE_FLOW_CREDENTIAL_PROVIDER = "antigravity"; + +/** Map a video provider id to the provider id whose stored credentials it uses. */ +export function resolveVideoCredentialProvider(provider: string): string { + return provider === "googleflow" ? GOOGLE_FLOW_CREDENTIAL_PROVIDER : provider; +} + +// --- Wire endpoint (isolated; confirm against a real Flow HAR — Rule #18) --- +export const GOOGLE_FLOW_HOST = "https://aisandbox-pa.googleapis.com"; +export const GOOGLE_FLOW_SUBMIT_PATH = "/v1:generateVideo"; +export const GOOGLE_FLOW_POLL_PATH = "/v1:fetchOperation"; + +export interface FlowVideoParams { + prompt: string; + aspectRatio?: string; + durationSeconds?: number; + sampleCount: number; + negativePrompt?: string; + resolution?: string; +} + +export interface FlowOperationResult { + done: boolean; + error?: string; + base64?: string; + url?: string; + format?: string; +} + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +const ASPECT_RATIO_RE = /^\d{1,2}:\d{1,2}$/; + +/** + * Normalize an OpenAI-style /v1/videos/generations body into Veo parameters. + * Accepts both snake_case (OpenAI) and camelCase (native) field names, and + * treats a `size` that looks like a ratio (e.g. "16:9") as the aspect ratio. + */ +export function normalizeFlowVideoParams(body: Record | null | undefined): FlowVideoParams { + const b = body ?? {}; + const prompt = typeof b.prompt === "string" ? b.prompt : String(b.prompt ?? ""); + + const sizeMaybeRatio = asTrimmedString(b.size); + const aspectRatio = + asTrimmedString(b.aspect_ratio) ?? + asTrimmedString(b.aspectRatio) ?? + (sizeMaybeRatio && ASPECT_RATIO_RE.test(sizeMaybeRatio) ? sizeMaybeRatio : undefined); + + const durationRaw = + typeof b.duration === "number" + ? b.duration + : typeof b.durationSeconds === "number" + ? b.durationSeconds + : undefined; + const durationSeconds = + typeof durationRaw === "number" && Number.isFinite(durationRaw) && durationRaw > 0 + ? Math.floor(durationRaw) + : undefined; + + const nRaw = typeof b.n === "number" ? b.n : undefined; + const sampleCount = + typeof nRaw === "number" && Number.isFinite(nRaw) && nRaw > 0 ? Math.floor(nRaw) : 1; + + return { + prompt, + aspectRatio, + durationSeconds, + sampleCount, + negativePrompt: asTrimmedString(b.negative_prompt) ?? asTrimmedString(b.negativePrompt), + resolution: asTrimmedString(b.resolution), + }; +} + +/** + * Build the documented Veo `predictLongRunning` request body for the submit call. + * Only includes optional parameters that were actually provided. + */ +export function buildGoogleFlowSubmitBody(params: FlowVideoParams): { + instances: Array>; + parameters: Record; +} { + const parameters: Record = { sampleCount: params.sampleCount }; + if (params.aspectRatio) parameters.aspectRatio = params.aspectRatio; + if (typeof params.durationSeconds === "number") parameters.durationSeconds = params.durationSeconds; + if (params.negativePrompt) parameters.negativePrompt = params.negativePrompt; + if (params.resolution) parameters.resolution = params.resolution; + + return { + instances: [{ prompt: params.prompt }], + parameters, + }; +} + +/** + * Extract the long-running-operation name from a submit response. + * Tolerates both `{ name }` and `{ operation: { name } }` envelopes. + */ +export function parseFlowOperationName(json: unknown): string | null { + if (!json || typeof json !== "object") return null; + const direct = (json as { name?: unknown }).name; + if (typeof direct === "string" && direct.length > 0) return direct; + const op = (json as { operation?: { name?: unknown } }).operation; + if (op && typeof op === "object" && typeof op.name === "string" && op.name.length > 0) { + return op.name; + } + return null; +} + +function extractVideoFromResponse(response: unknown): { base64?: string; url?: string } | null { + if (!response || typeof response !== "object") return null; + + // Documented Veo LRO shape: response.videos[].bytesBase64Encoded | gcsUri + const videos = (response as { videos?: unknown }).videos; + if (Array.isArray(videos) && videos.length > 0) { + const v = videos[0]; + if (v && typeof v === "object") { + const rec = v as Record; + if (typeof rec.bytesBase64Encoded === "string") return { base64: rec.bytesBase64Encoded }; + if (typeof rec.gcsUri === "string") return { url: rec.gcsUri }; + if (typeof rec.uri === "string") return { url: rec.uri }; + } + } + + // Alternate Veo shape: response.generateVideoResponse.generatedSamples[].video.uri + const gen = (response as { generateVideoResponse?: { generatedSamples?: unknown } }) + .generateVideoResponse; + const samples = gen && typeof gen === "object" ? gen.generatedSamples : undefined; + if (Array.isArray(samples) && samples.length > 0) { + const sample = samples[0]; + const video = + sample && typeof sample === "object" + ? (sample as { video?: unknown }).video + : undefined; + if (video && typeof video === "object") { + const rec = video as Record; + if (typeof rec.uri === "string") return { url: rec.uri }; + if (typeof rec.bytesBase64Encoded === "string") return { base64: rec.bytesBase64Encoded }; + } + } + + return null; +} + +/** + * Interpret a poll/fetch-operation response into a normalized result. + * `done: false` means still running; callers should keep polling. + */ +export function parseFlowOperationResult(json: unknown): FlowOperationResult { + if (!json || typeof json !== "object") return { done: false }; + + const done = Boolean((json as { done?: unknown }).done); + if (!done) return { done: false }; + + const opError = (json as { error?: { message?: unknown } }).error; + if (opError && typeof opError === "object") { + return { done: true, error: String(opError.message || "Google Flow video operation failed") }; + } + + const response = (json as { response?: unknown }).response; + const video = extractVideoFromResponse(response); + if (video?.base64) return { done: true, base64: video.base64, format: "mp4" }; + if (video?.url) return { done: true, url: video.url, format: "mp4" }; + + return { done: true, error: "Google Flow operation completed but returned no video" }; +} + +/** Resolve the Cloud Code projectId from the OAuth credential record (mirrors Antigravity). */ +export function resolveFlowProjectId( + credentials: Record | null | undefined +): string | null { + const cred = credentials ?? {}; + const direct = asTrimmedString(cred.projectId); + if (direct) return direct; + const psd = cred.providerSpecificData; + if (psd && typeof psd === "object") { + const fromPsd = asTrimmedString((psd as Record).projectId); + if (fromPsd) return fromPsd; + } + return null; +} + +/** Resolve the OAuth bearer token from the credential record. */ +export function resolveFlowAccessToken( + credentials: Record | null | undefined +): string | null { + const cred = credentials ?? {}; + return asTrimmedString(cred.accessToken) ?? asTrimmedString(cred.apiKey) ?? null; +} diff --git a/open-sse/handlers/videoGeneration/googleFlowHandler.ts b/open-sse/handlers/videoGeneration/googleFlowHandler.ts new file mode 100644 index 000000000000..9b09d00fd92d --- /dev/null +++ b/open-sse/handlers/videoGeneration/googleFlowHandler.ts @@ -0,0 +1,145 @@ +/** + * Veo video generation via Google Flow (labs.google/flow) — request orchestration. + * + * Uses the Google account OAuth bearer + Cloud Code projectId that the Antigravity + * provider already establishes — no separate OAuth flow is added. Submits the + * documented Veo `predictLongRunning` body to Google's AI Sandbox endpoint, polls + * the long-running operation, and returns the MP4 (base64 or URL). + * + * ⚠️ PENDING LIVE VALIDATION (Hard Rule #18): the AI-Sandbox host/path and the + * Cloud-Code request envelope cannot be unit-tested — they require a real Google + * Flow account + a captured HAR. The wire surface is isolated to the two path + * constants (`GOOGLE_FLOW_SUBMIT_PATH`/`GOOGLE_FLOW_POLL_PATH`) and the `project` + * wrap below, so confirming a captured HAR is a one-line change. The pure + * transformation helpers are fully unit-tested (google-flow-video-4569.test.ts). + */ + +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { + GOOGLE_FLOW_POLL_PATH, + GOOGLE_FLOW_SUBMIT_PATH, + buildGoogleFlowSubmitBody, + normalizeFlowVideoParams, + parseFlowOperationName, + parseFlowOperationResult, + resolveFlowAccessToken, + resolveFlowProjectId, +} from "./googleFlow.ts"; + +interface GoogleFlowHandlerArgs { + model: string; + providerConfig: { baseUrl: string }; + body: Record; + credentials: Record | null; + log?: { info?: (tag: string, msg: string) => void; error?: (tag: string, msg: string) => void }; +} + +const POLL_INTERVAL_MS = 10_000; +const MAX_WAIT_MS = 5 * 60 * 1000; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export async function handleGoogleFlowVideoGeneration({ + model, + providerConfig, + body, + credentials, + log, +}: GoogleFlowHandlerArgs) { + const token = resolveFlowAccessToken(credentials); + if (!token) { + return { + success: false, + status: 401, + error: + "Missing Google OAuth token for Google Flow. Connect a Google account in Providers (the Antigravity/Cloud Code connection) first.", + }; + } + + const projectId = resolveFlowProjectId(credentials); + if (!projectId) { + return { + success: false, + status: 400, + error: + "Missing Google projectId for Google Flow. Please reconnect OAuth in Providers so OmniRoute can fetch your Cloud Code project.", + }; + } + + const params = normalizeFlowVideoParams(body); + const submitBody = buildGoogleFlowSubmitBody(params); + // PENDING LIVE VALIDATION: Cloud-Code envelope wraps the Veo body with `project`/`model`. + const wireBody = { ...submitBody, project: projectId, model }; + + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }; + + try { + log?.info?.( + "VIDEO", + `googleflow/${model} (veo) | submitting | aspect: ${params.aspectRatio ?? "default"}` + ); + const submitRes = await fetch(`${baseUrl}${GOOGLE_FLOW_SUBMIT_PATH}`, { + method: "POST", + headers, + body: JSON.stringify(wireBody), + }); + if (!submitRes.ok) { + const errorText = await submitRes.text(); + return { + success: false, + status: submitRes.status, + error: sanitizeErrorMessage(`Google Flow submit failed: ${errorText.slice(0, 300)}`), + }; + } + + const operationName = parseFlowOperationName(await submitRes.json()); + if (!operationName) { + return { success: false, status: 502, error: "Google Flow did not return an operation name" }; + } + + const deadline = Date.now() + MAX_WAIT_MS; + while (Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS); + const pollRes = await fetch(`${baseUrl}${GOOGLE_FLOW_POLL_PATH}`, { + method: "POST", + headers, + body: JSON.stringify({ operationName }), + }); + if (!pollRes.ok) { + const errorText = await pollRes.text(); + return { + success: false, + status: pollRes.status, + error: sanitizeErrorMessage(`Google Flow poll failed: ${errorText.slice(0, 300)}`), + }; + } + + const result = parseFlowOperationResult(await pollRes.json()); + if (!result.done) continue; + if (result.error) { + return { success: false, status: 502, error: sanitizeErrorMessage(result.error) }; + } + const item = result.base64 + ? { b64_json: result.base64, format: result.format } + : { url: result.url, format: result.format }; + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: [item] }, + }; + } + + return { success: false, status: 504, error: "Google Flow video generation timed out" }; + } catch (err) { + const e = (err ?? {}) as { message?: string; status?: number }; + log?.error?.("VIDEO", `Google Flow generation failed: ${e.message}`); + return { + success: false, + status: typeof e.status === "number" ? e.status : 502, + error: sanitizeErrorMessage(e.message || "Google Flow generation failed"), + }; + } +} diff --git a/src/app/api/v1/videos/generations/route.ts b/src/app/api/v1/videos/generations/route.ts index 065285f3c3f0..8f0153f85a90 100644 --- a/src/app/api/v1/videos/generations/route.ts +++ b/src/app/api/v1/videos/generations/route.ts @@ -1,4 +1,5 @@ import { handleVideoGeneration } from "@omniroute/open-sse/handlers/videoGeneration.ts"; +import { resolveVideoCredentialProvider } from "@omniroute/open-sse/handlers/videoGeneration/googleFlow.ts"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; import { getProviderCredentials, @@ -99,10 +100,12 @@ async function postHandler(request, context) { // Check provider config for auth bypass const providerConfig = getVideoProvider(provider); - // Get credentials — skip for local providers (authType: "none") + // Get credentials — skip for local providers (authType: "none"). + // Google Flow has no standalone connection: it reuses the Antigravity Google + // OAuth credential (resolveVideoCredentialProvider maps googleflow → antigravity). let credentials = null; if (providerConfig && providerConfig.authType !== "none") { - credentials = await getProviderCredentials(provider); + credentials = await getProviderCredentials(resolveVideoCredentialProvider(provider)); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, diff --git a/tests/unit/google-flow-video-4569.test.ts b/tests/unit/google-flow-video-4569.test.ts new file mode 100644 index 000000000000..4f7e5f588911 --- /dev/null +++ b/tests/unit/google-flow-video-4569.test.ts @@ -0,0 +1,214 @@ +/** + * #4569 — Google Flow video generation helpers. + * + * Covers the pure transformation logic (param normalization, Veo submit-body + * construction, operation-name parsing, LRO result parsing, credential + * resolution) and a source guard asserting the wire endpoint stays isolated and + * flagged for live validation (Hard Rule #18). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { + GOOGLE_FLOW_CREDENTIAL_PROVIDER, + GOOGLE_FLOW_HOST, + buildGoogleFlowSubmitBody, + normalizeFlowVideoParams, + parseFlowOperationName, + parseFlowOperationResult, + resolveFlowAccessToken, + resolveFlowProjectId, + resolveVideoCredentialProvider, +} from "../../open-sse/handlers/videoGeneration/googleFlow.ts"; + +import { getVideoProvider, parseVideoModel } from "../../open-sse/config/videoRegistry.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +test("normalizeFlowVideoParams: defaults sampleCount to 1 and stringifies prompt", () => { + const p = normalizeFlowVideoParams({ prompt: "a cat surfing" }); + assert.equal(p.prompt, "a cat surfing"); + assert.equal(p.sampleCount, 1); + assert.equal(p.aspectRatio, undefined); + assert.equal(p.durationSeconds, undefined); +}); + +test("normalizeFlowVideoParams: reads snake_case OpenAI fields", () => { + const p = normalizeFlowVideoParams({ + prompt: "x", + aspect_ratio: "16:9", + duration: 8, + n: 2, + negative_prompt: "blurry", + resolution: "1080p", + }); + assert.equal(p.aspectRatio, "16:9"); + assert.equal(p.durationSeconds, 8); + assert.equal(p.sampleCount, 2); + assert.equal(p.negativePrompt, "blurry"); + assert.equal(p.resolution, "1080p"); +}); + +test("normalizeFlowVideoParams: reads camelCase native fields", () => { + const p = normalizeFlowVideoParams({ prompt: "x", aspectRatio: "9:16", durationSeconds: 4 }); + assert.equal(p.aspectRatio, "9:16"); + assert.equal(p.durationSeconds, 4); +}); + +test("normalizeFlowVideoParams: treats ratio-shaped size as aspectRatio, ignores pixel size", () => { + assert.equal(normalizeFlowVideoParams({ prompt: "x", size: "16:9" }).aspectRatio, "16:9"); + assert.equal(normalizeFlowVideoParams({ prompt: "x", size: "1024x1024" }).aspectRatio, undefined); +}); + +test("normalizeFlowVideoParams: rejects non-positive / non-finite numbers", () => { + const p = normalizeFlowVideoParams({ prompt: "x", n: 0, duration: -5 }); + assert.equal(p.sampleCount, 1); + assert.equal(p.durationSeconds, undefined); +}); + +test("buildGoogleFlowSubmitBody: emits documented Veo instances/parameters shape", () => { + const body = buildGoogleFlowSubmitBody({ + prompt: "a dog", + aspectRatio: "16:9", + durationSeconds: 6, + sampleCount: 1, + negativePrompt: "ugly", + resolution: "720p", + }); + assert.deepEqual(body.instances, [{ prompt: "a dog" }]); + assert.equal(body.parameters.sampleCount, 1); + assert.equal(body.parameters.aspectRatio, "16:9"); + assert.equal(body.parameters.durationSeconds, 6); + assert.equal(body.parameters.negativePrompt, "ugly"); + assert.equal(body.parameters.resolution, "720p"); +}); + +test("buildGoogleFlowSubmitBody: omits absent optional parameters", () => { + const body = buildGoogleFlowSubmitBody({ prompt: "p", sampleCount: 3 }); + assert.deepEqual(body.parameters, { sampleCount: 3 }); + assert.ok(!("aspectRatio" in body.parameters)); + assert.ok(!("durationSeconds" in body.parameters)); +}); + +test("parseFlowOperationName: handles {name} and {operation:{name}} and rejects junk", () => { + assert.equal(parseFlowOperationName({ name: "operations/abc" }), "operations/abc"); + assert.equal(parseFlowOperationName({ operation: { name: "operations/xyz" } }), "operations/xyz"); + assert.equal(parseFlowOperationName({}), null); + assert.equal(parseFlowOperationName(null), null); + assert.equal(parseFlowOperationName({ name: "" }), null); +}); + +test("parseFlowOperationResult: not done → keep polling", () => { + assert.deepEqual(parseFlowOperationResult({ done: false }), { done: false }); + assert.deepEqual(parseFlowOperationResult({}), { done: false }); +}); + +test("parseFlowOperationResult: done with base64 video (documented shape)", () => { + const r = parseFlowOperationResult({ + done: true, + response: { videos: [{ bytesBase64Encoded: "AAAA" }] }, + }); + assert.equal(r.done, true); + assert.equal(r.base64, "AAAA"); + assert.equal(r.format, "mp4"); + assert.equal(r.error, undefined); +}); + +test("parseFlowOperationResult: done with gcsUri/uri video", () => { + assert.equal( + parseFlowOperationResult({ done: true, response: { videos: [{ gcsUri: "gs://b/v.mp4" }] } }).url, + "gs://b/v.mp4" + ); + assert.equal( + parseFlowOperationResult({ done: true, response: { videos: [{ uri: "https://x/v.mp4" }] } }).url, + "https://x/v.mp4" + ); +}); + +test("parseFlowOperationResult: done with alternate generateVideoResponse shape", () => { + const r = parseFlowOperationResult({ + done: true, + response: { + generateVideoResponse: { generatedSamples: [{ video: { uri: "https://x/sample.mp4" } }] }, + }, + }); + assert.equal(r.url, "https://x/sample.mp4"); +}); + +test("parseFlowOperationResult: done with error surfaces the message", () => { + const r = parseFlowOperationResult({ done: true, error: { message: "quota exceeded" } }); + assert.equal(r.done, true); + assert.equal(r.error, "quota exceeded"); +}); + +test("parseFlowOperationResult: done but empty → explicit no-video error", () => { + const r = parseFlowOperationResult({ done: true, response: {} }); + assert.equal(r.done, true); + assert.match(r.error ?? "", /no video/i); +}); + +test("parseFlowOperationResult: null/empty array elements do not crash (defensive)", () => { + // Upstream returning a null element must not throw a TypeError (gemini-code-assist #4769). + for (const response of [ + { videos: [null] }, + { videos: [undefined] }, + { videos: [{}] }, + { generateVideoResponse: { generatedSamples: [null] } }, + { generateVideoResponse: { generatedSamples: [{ video: null }] } }, + { generateVideoResponse: { generatedSamples: [{}] } }, + ]) { + const r = parseFlowOperationResult({ done: true, response }); + assert.equal(r.done, true); + assert.match(r.error ?? "", /no video/i); + assert.equal(r.base64, undefined); + assert.equal(r.url, undefined); + } +}); + +test("resolveFlowProjectId: direct, providerSpecificData, and missing", () => { + assert.equal(resolveFlowProjectId({ projectId: "proj-1" }), "proj-1"); + assert.equal(resolveFlowProjectId({ providerSpecificData: { projectId: "proj-2" } }), "proj-2"); + assert.equal(resolveFlowProjectId({}), null); + assert.equal(resolveFlowProjectId(null), null); +}); + +test("resolveFlowAccessToken: prefers accessToken, falls back to apiKey", () => { + assert.equal(resolveFlowAccessToken({ accessToken: "tok" }), "tok"); + assert.equal(resolveFlowAccessToken({ apiKey: "key" }), "key"); + assert.equal(resolveFlowAccessToken({}), null); +}); + +test("videoRegistry: googleflow provider is registered with oauth + google-flow format", () => { + const provider = getVideoProvider("googleflow"); + assert.ok(provider, "googleflow provider must exist"); + assert.equal(provider.format, "google-flow"); + assert.equal(provider.authType, "oauth"); + assert.ok(provider.models.length > 0, "must expose at least one Veo model"); +}); + +test("videoRegistry: parseVideoModel resolves googleflow/ and its alias", () => { + const parsed = parseVideoModel("googleflow/veo-3.1-generate"); + assert.equal(parsed.provider, "googleflow"); + assert.equal(parsed.model, "veo-3.1-generate"); + const aliased = parseVideoModel("flow/veo-3.1-generate"); + assert.equal(aliased.provider, "googleflow"); +}); + +test("resolveVideoCredentialProvider: googleflow reuses antigravity OAuth, others unchanged", () => { + assert.equal(resolveVideoCredentialProvider("googleflow"), "antigravity"); + assert.equal(GOOGLE_FLOW_CREDENTIAL_PROVIDER, "antigravity"); + assert.equal(resolveVideoCredentialProvider("vertex"), "vertex"); + assert.equal(resolveVideoCredentialProvider("kie"), "kie"); +}); + +test("source guard: wire endpoint stays isolated + flagged for live validation (Rule #18)", () => { + assert.match(GOOGLE_FLOW_HOST, /aisandbox-pa\.googleapis\.com/); + const src = readFileSync( + join(__dirname, "../../open-sse/handlers/videoGeneration/googleFlow.ts"), + "utf8" + ); + assert.match(src, /PENDING LIVE VALIDATION/, "wire format must be flagged for HAR validation"); +}); From fcf162d3fec61bb46d041f226bfdfcc114e3cc47 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:37:25 -0300 Subject: [PATCH 018/120] fix(api): auth on compression run-telemetry + document OMNIROUTE_EVAL_CREDENTIALS (#4694, #4720) (#4796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — auth on compression run-telemetry + OMNIROUTE_EVAL_CREDENTIALS doc, release-green validated (typecheck + 3 tests + env-doc-sync) --- .env.example | 5 +++ docs/reference/ENVIRONMENT.md | 9 ++++ .../compression/run-telemetry/route.ts | 9 +++- .../compression-run-telemetry-auth.test.ts | 45 +++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/unit/compression-run-telemetry-auth.test.ts diff --git a/.env.example b/.env.example index 3335784a6c08..26d6ee99e76f 100644 --- a/.env.example +++ b/.env.example @@ -598,6 +598,11 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500 # Used by: scripts/postinstall.mjs. #OMNIROUTE_SKIP_POSTINSTALL=0 +# Operator-supplied JSON credentials for the offline compression-eval CLI +# (parsed with JSON.parse; leave unset for a dry run). Developer tooling only. +# Used by: scripts/compression-eval/index.ts. Default: {} (empty). +#OMNIROUTE_EVAL_CREDENTIALS={} + # Skip the DB healthcheck entirely on startup (useful for short-lived tasks / tests). # Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to disable. Default: 0. #OMNIROUTE_SKIP_DB_HEALTHCHECK=0 diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index ffebb7a27d3d..ad9a789b522d 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1082,3 +1082,12 @@ is developer tooling only. | `OMNIROUTE_URL` | `http://localhost:20128` | `scripts/ad-hoc/regen-opencode-config.ts` | Base URL of the OmniRoute instance to query for `/v1/models`. | | `OMNIROUTE_KEY` | _(unset)_ | `scripts/ad-hoc/regen-opencode-config.ts` | API key to authenticate against the OmniRoute `/v1/models` endpoint. Falls back to `OPENCODE_API_KEY` when unset. | | `OPENCODE_API_KEY` | _(unset)_ | `scripts/ad-hoc/regen-opencode-config.ts` | OpenCode-style API key (`sk-...`) written into the regenerated `opencode.json`. Falls back to `OMNIROUTE_KEY` when unset. | + +### Compression offline-eval harness (ad-hoc tooling) + +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 | +| --------------------------- | ------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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/src/app/api/settings/compression/run-telemetry/route.ts b/src/app/api/settings/compression/run-telemetry/route.ts index 04288d713c17..d5e797a75fd8 100644 --- a/src/app/api/settings/compression/run-telemetry/route.ts +++ b/src/app/api/settings/compression/run-telemetry/route.ts @@ -1,9 +1,14 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; import { getCompressionRunTelemetrySummary } from "@/lib/db/compressionRunTelemetry"; export const dynamic = "force-dynamic"; -export async function GET() { +export async function GET(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { const summary = getCompressionRunTelemetrySummary(); return NextResponse.json(summary); diff --git a/tests/unit/compression-run-telemetry-auth.test.ts b/tests/unit/compression-run-telemetry-auth.test.ts new file mode 100644 index 000000000000..cabfb663845d --- /dev/null +++ b/tests/unit/compression-run-telemetry-auth.test.ts @@ -0,0 +1,45 @@ +/** + * Security regression (#4694 sibling-path gate parity): the compression + * run-telemetry route MUST authenticate before disclosing telemetry, mirroring + * its sibling `settings/compression/route.ts`. A behavioral 401 test would need + * full DB/settings bootstrap (isAuthenticated reads settings), so this is a + * source guard that fails if the auth gate is removed or reordered after the + * data call — the same wiring-guard pattern used elsewhere in the suite. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROUTE = join( + __dirname, + "../../src/app/api/settings/compression/run-telemetry/route.ts" +); + +test("run-telemetry GET imports isAuthenticated from the shared auth util", () => { + const src = readFileSync(ROUTE, "utf8"); + assert.match(src, /import\s*\{[^}]*\bisAuthenticated\b[^}]*\}\s*from\s*["']@\/shared\/utils\/apiAuth["']/); +}); + +test("run-telemetry GET gates on auth and returns 401 before reading telemetry", () => { + const src = readFileSync(ROUTE, "utf8"); + + const authIdx = src.indexOf("isAuthenticated(request)"); + const unauthorizedIdx = src.search(/status:\s*401/); + const dataIdx = src.indexOf("getCompressionRunTelemetrySummary("); + + assert.ok(authIdx > 0, "must call isAuthenticated(request)"); + assert.ok(unauthorizedIdx > 0, "must return a 401 on failed auth"); + assert.ok(dataIdx > 0, "must call getCompressionRunTelemetrySummary"); + + // The auth check and its 401 must come BEFORE the telemetry read. + assert.ok(authIdx < dataIdx, "auth check must precede the telemetry read"); + assert.ok(unauthorizedIdx < dataIdx, "401 response must precede the telemetry read"); +}); + +test("run-telemetry GET receives the request object (so auth can read it)", () => { + const src = readFileSync(ROUTE, "utf8"); + assert.match(src, /export\s+async\s+function\s+GET\s*\(\s*request\s*:/); +}); From fc2fdc7e3c7b1bd6bee646b22d30ee113740ec88 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:50:28 -0300 Subject: [PATCH 019/120] fix(translator): strip top-level client_metadata on the OpenAI passthrough (port from 9router#1157) (#4624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) --- open-sse/translator/helpers/openaiHelper.ts | 3 +++ ...nslator-strip-client-metadata-1157.test.ts | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 tests/unit/translator-strip-client-metadata-1157.test.ts diff --git a/open-sse/translator/helpers/openaiHelper.ts b/open-sse/translator/helpers/openaiHelper.ts index b53bbb64e775..502d309fe36d 100644 --- a/open-sse/translator/helpers/openaiHelper.ts +++ b/open-sse/translator/helpers/openaiHelper.ts @@ -148,6 +148,9 @@ export function filterToOpenAIFormat(body) { // Strip Claude-specific fields that OpenAI-compatible providers reject delete body.metadata; delete body.anthropic_version; + // Codex clients send a top-level `client_metadata` object; OpenAI rejects it + // with 400 "Unknown parameter: 'client_metadata'" (9router#1157). + delete body.client_metadata; // Map max_output_tokens (from Vercel AI SDK) to max_tokens logic if (body.max_output_tokens !== undefined) { diff --git a/tests/unit/translator-strip-client-metadata-1157.test.ts b/tests/unit/translator-strip-client-metadata-1157.test.ts new file mode 100644 index 000000000000..243b1407a3b0 --- /dev/null +++ b/tests/unit/translator-strip-client-metadata-1157.test.ts @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const openaiHelper = await import("../../open-sse/translator/helpers/openaiHelper.ts"); + +// Regression for upstream issue 9router#1157: Codex-origin requests carry a +// top-level `client_metadata` object. On the OpenAI->OpenAI chat-completions +// passthrough, `filterToOpenAIFormat` is the only sanitizer, and it forwarded +// `client_metadata` to api.openai.com, which rejects it with +// 400 "Unknown parameter: 'client_metadata'". It must be stripped alongside +// the other Claude/Codex-specific fields. +test("filterToOpenAIFormat strips top-level client_metadata (9router#1157)", () => { + const body = { + messages: [{ role: "user", content: "hi" }], + client_metadata: { user_id: "abc", session_id: "xyz" }, + metadata: { remove: true }, + anthropic_version: "2023-06-01", + }; + + const result = openaiHelper.filterToOpenAIFormat(body); + + assert.equal("client_metadata" in result, false); + assert.equal("metadata" in result, false); + assert.equal("anthropic_version" in result, false); + assert.equal(result.messages.length, 1); +}); From ea406b21975371f493e78a6c2e7ae1f825067285 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:50:37 -0300 Subject: [PATCH 020/120] fix(translator): normalize `developer` role to `system` for OpenAI-format providers (#4625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) --- open-sse/translator/helpers/openaiHelper.ts | 5 +++ .../openai-developer-role-normalize.test.ts | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 tests/unit/openai-developer-role-normalize.test.ts diff --git a/open-sse/translator/helpers/openaiHelper.ts b/open-sse/translator/helpers/openaiHelper.ts index 502d309fe36d..375364c725a1 100644 --- a/open-sse/translator/helpers/openaiHelper.ts +++ b/open-sse/translator/helpers/openaiHelper.ts @@ -35,6 +35,11 @@ export function filterToOpenAIFormat(body) { if (!body.messages || !Array.isArray(body.messages)) return body; body.messages = body.messages.map((msg) => { + // Normalize OpenAI Responses-style `developer` role to `system` — many + // OpenAI-compatible providers reject `developer` (ported from + // decolua/9router#1011). + if (msg.role === "developer") msg = { ...msg, role: "system" }; + // Keep tool messages as-is (OpenAI format) if (msg.role === "tool") return msg; diff --git a/tests/unit/openai-developer-role-normalize.test.ts b/tests/unit/openai-developer-role-normalize.test.ts new file mode 100644 index 000000000000..8d6d5190eee5 --- /dev/null +++ b/tests/unit/openai-developer-role-normalize.test.ts @@ -0,0 +1,38 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { filterToOpenAIFormat } from "../../open-sse/translator/helpers/openaiHelper.ts"; + +// Ported from upstream decolua/9router#1011 — many OpenAI-compatible providers +// reject the `developer` role (introduced by OpenAI Responses API). Normalize to +// `system` so passthrough requests keep working downstream. +test("filterToOpenAIFormat normalizes developer role to system", () => { + const body = { + messages: [ + { role: "developer", content: "You are helpful" }, + { role: "user", content: "Hi" }, + ], + }; + + const out = filterToOpenAIFormat(body); + + assert.equal(out.messages[0].role, "system"); + assert.equal(out.messages[0].content, "You are helpful"); + assert.equal(out.messages[1].role, "user"); +}); + +test("filterToOpenAIFormat normalizes developer role with array content", () => { + const body = { + messages: [ + { + role: "developer", + content: [{ type: "text", text: "Be concise" }], + }, + ], + }; + + const out = filterToOpenAIFormat(body); + + assert.equal(out.messages[0].role, "system"); + assert.deepEqual(out.messages[0].content, [{ type: "text", text: "Be concise" }]); +}); From 2a90a9e589d44ba975e3b4d79f29ccf1fe42e695 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:50:54 -0300 Subject: [PATCH 021/120] fix(translator): emit close marker for Anthropic thinking blocks (#4633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) --- .../translator/response/claude-to-openai.ts | 8 +- ...ropic-stream-thinking-close-marker.test.ts | 109 ++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 tests/unit/anthropic-stream-thinking-close-marker.test.ts diff --git a/open-sse/translator/response/claude-to-openai.ts b/open-sse/translator/response/claude-to-openai.ts index 22af8213b651..da553489aeb6 100644 --- a/open-sse/translator/response/claude-to-openai.ts +++ b/open-sse/translator/response/claude-to-openai.ts @@ -102,8 +102,12 @@ export function claudeToOpenAIResponse(chunk, state) { case "content_block_stop": { if (state.inThinkingBlock && chunk.index === state.currentBlockIndex) { - // Thinking block closed — no additional content needed; - // reasoning_content chunks have already been streamed + // Emit explicit close marker so clients that scan content for `` + // (Claude Code, Cursor, etc.) know the thinking section ended; without + // it the UI stays stuck on the "thinking" indicator after the upstream + // stream completes. Ported from decolua/9router#454. `reasoning_content` + // consumers are unaffected — they already saw the streamed deltas above. + results.push(createChunk(state, { content: "" })); state.inThinkingBlock = false; } state.textBlockStarted = false; diff --git a/tests/unit/anthropic-stream-thinking-close-marker.test.ts b/tests/unit/anthropic-stream-thinking-close-marker.test.ts new file mode 100644 index 000000000000..8b8e5e4e48f3 --- /dev/null +++ b/tests/unit/anthropic-stream-thinking-close-marker.test.ts @@ -0,0 +1,109 @@ +// Regression test for the Anthropic-compatible stream "thinking block never closes" bug. +// +// When clients consume the OpenAI-compatible stream that OmniRoute synthesises from +// Claude-native SSE, they need an explicit signal that the thinking/reasoning section +// has ended; otherwise the UI stays stuck on the "thinking" indicator even after the +// upstream stream has cleanly completed. +// +// Inspired by upstream decolua/9router PR #454. +// +// Before the fix, the `content_block_stop` event for a thinking block emitted NO +// terminating chunk at all (a previous drift had emitted `reasoning_content: ""`, +// which is semantically a no-op and does not signal "thinking complete" to clients +// such as Claude Code). +// +// The fix emits a `content: ""` chunk on close — matching the convention +// already used throughout OmniRoute (see openai-responses.ts / responsesTransformer.ts +// which split on `` to separate reasoning from final content). + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { claudeToOpenAIResponse } = await import( + "../../open-sse/translator/response/claude-to-openai.ts" +); + +function newState() { + return { + toolCalls: new Map(), + toolNameMap: new Map(), + messageId: "msg_test", + model: "claude-3-7-sonnet", + toolCallIndex: 0, + }; +} + +test("claudeToOpenAIResponse emits close marker on thinking content_block_stop", () => { + const state = newState(); + + // Open thinking block. + claudeToOpenAIResponse( + { + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "" }, + }, + state + ); + + // Stream reasoning delta. + claudeToOpenAIResponse( + { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "Plan first." }, + }, + state + ); + + // Close the thinking block. + const closeChunks = claudeToOpenAIResponse( + { type: "content_block_stop", index: 0 }, + state + ); + + assert.ok(Array.isArray(closeChunks), "stop event must return an array of chunks"); + assert.ok( + closeChunks.length >= 1, + "stop event for thinking block must emit at least one close-marker chunk" + ); + + const hasCloseMarker = closeChunks.some( + (chunk) => chunk?.choices?.[0]?.delta?.content === "" + ); + assert.ok( + hasCloseMarker, + `expected a chunk with delta.content === ""; got ${JSON.stringify(closeChunks)}` + ); + + // After close, state flag must be cleared so subsequent thinking blocks are tracked correctly. + assert.equal(state.inThinkingBlock, false); +}); + +test("claudeToOpenAIResponse does not emit on stop of non-thinking blocks", () => { + const state = newState(); + + // Open + immediately close a text block — must NOT inject . + claudeToOpenAIResponse( + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + state + ); + const closeChunks = claudeToOpenAIResponse( + { type: "content_block_stop", index: 0 }, + state + ); + + const arr = Array.isArray(closeChunks) ? closeChunks : []; + const hasCloseMarker = arr.some( + (chunk) => chunk?.choices?.[0]?.delta?.content === "" + ); + assert.equal( + hasCloseMarker, + false, + "text-block close must not emit sentinel" + ); +}); From a9368af7df7a47254b0e5fc8321724bcc0cf5147 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:51:08 -0300 Subject: [PATCH 022/120] fix(translator): normalize tools to Anthropic-native shape for non-Anthropic providers (#4650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) --- open-sse/translator/helpers/claudeHelper.ts | 27 ++++ .../translator-tools-anthropic-shape.test.ts | 119 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 tests/unit/translator-tools-anthropic-shape.test.ts diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index 43b1a731325e..6d8c8edb81c2 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -302,6 +302,33 @@ export function prepareClaudeRequest( } } + // Tools: for non-Anthropic providers (MiniMax and other Anthropic-compatible + // Claude-shape endpoints) strip Anthropic-only built-in tools (e.g. + // web_search_20250305) and normalize OpenAI-wire-shape tools to the + // Anthropic-native shape — fold `function.{name,description,parameters}` + // into top-level `{name, description, input_schema}` and drop the stray + // `type` field. Without this MiniMax rejects with code 2013 ("invalid + // tool type"). Port of upstream decolua/9router@45240c19. + if (body.tools && Array.isArray(body.tools) && provider !== "claude") { + body.tools = body.tools + .filter((tool) => !tool.type || tool.type === "function") + .map((tool) => { + const t = tool as ClaudeTool & { + function?: { name?: string; description?: string; parameters?: unknown }; + type?: string; + }; + if (t.function) { + return { + name: t.function.name, + description: t.function.description, + input_schema: t.function.parameters, + } as ClaudeTool; + } + const { type: _type, ...rest } = t; + return rest as ClaudeTool; + }); + } + // Also filter top-level tool declarations with empty names if (body.tools && Array.isArray(body.tools)) { body.tools = body.tools.filter((tool) => tool.name && tool.name?.trim()); diff --git a/tests/unit/translator-tools-anthropic-shape.test.ts b/tests/unit/translator-tools-anthropic-shape.test.ts new file mode 100644 index 000000000000..824fe1d1dbbe --- /dev/null +++ b/tests/unit/translator-tools-anthropic-shape.test.ts @@ -0,0 +1,119 @@ +// Port of upstream decolua/9router@45240c19. +// Anthropic-compatible Claude-shape providers (MiniMax and friends) reject +// tools that carry a `type` field with error code 2013 ("invalid tool type"). +// When a client sends Anthropic-format requests but the tools are still in +// OpenAI wire shape — `{ type: "function", function: { name, description, +// parameters } }` — prepareClaudeRequest() must normalize them to the +// Anthropic-native shape `{ name, description, input_schema }` before +// forwarding to non-Anthropic providers. For Anthropic itself ("claude") +// the shape is left untouched so first-party server-side tools like +// web_search_20250305 keep flowing. +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { prepareClaudeRequest } from "../../open-sse/translator/helpers/claudeHelper.ts"; + +describe("prepareClaudeRequest tool-shape normalization for non-Anthropic providers", () => { + const buildBody = () => ({ + model: "MiniMax-M2.7", + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + max_tokens: 256, + tools: [ + // OpenAI wire shape inside an Anthropic-format request. + { + type: "function", + function: { + name: "get_weather", + description: "Get the weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + }, + // Anthropic-native shape (no `type` field at all) — must pass through unchanged. + { + name: "lookup_user", + description: "Find a user", + input_schema: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + }, + // Built-in Anthropic server-side tool that non-Anthropic providers + // must NOT receive at all. + { + type: "web_search_20250305", + name: "web_search", + }, + ], + }); + + test("folds function.{name,description,parameters} and drops `type` for minimax", () => { + const body = buildBody(); + const result = prepareClaudeRequest(body as any, "minimax"); + assert.ok(Array.isArray(result.tools)); + // Built-in web_search_20250305 must be stripped for non-Anthropic providers. + assert.equal(result.tools!.length, 2); + + const weather = result.tools!.find((t: any) => t.name === "get_weather") as any; + assert.ok(weather, "get_weather tool must survive normalization"); + assert.equal(weather.type, undefined, "`type` must be stripped"); + assert.equal(weather.function, undefined, "`function` wrapper must be folded"); + assert.equal(weather.description, "Get the weather"); + assert.deepEqual(weather.input_schema, { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }); + + const lookup = result.tools!.find((t: any) => t.name === "lookup_user") as any; + assert.ok(lookup, "lookup_user tool must survive normalization"); + assert.equal(lookup.type, undefined, "stray `type` must be stripped"); + assert.deepEqual(lookup.input_schema, { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }); + }); + + test("does NOT rewrite tool shape for first-party claude (keeps built-ins)", () => { + const body = { + model: "claude-sonnet-4-5", + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + max_tokens: 256, + tools: [ + // Anthropic-native shape. + { + name: "lookup_user", + description: "Find a user", + input_schema: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + }, + // Built-in Anthropic server-side tool — MUST be preserved for first-party claude. + { + type: "web_search_20250305", + name: "web_search", + }, + ], + }; + const result = prepareClaudeRequest(body as any, "claude"); + assert.ok(Array.isArray(result.tools)); + // Built-in web_search_20250305 must NOT be stripped for Anthropic-native upstream. + const webSearch = result.tools!.find((t: any) => t.type === "web_search_20250305"); + assert.ok(webSearch, "web_search_20250305 must be preserved for claude"); + assert.equal(webSearch!.type, "web_search_20250305"); + // Anthropic-shape tool is untouched. + const lookup = result.tools!.find((t: any) => t.name === "lookup_user") as any; + assert.ok(lookup); + assert.deepEqual(lookup.input_schema, { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }); + }); +}); From 35a3962cf0ac2586c51d510b82fd39679d96135d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:51:17 -0300 Subject: [PATCH 023/120] fix(gemini): preserve `pattern` in antigravity tool schema sanitizer (#4651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) --- open-sse/translator/helpers/geminiHelper.ts | 5 +- ...mini-antigravity-pattern-preserved.test.ts | 54 +++++++++++++++++++ ...tor-gemini-schema-pattern-property.test.ts | 13 ++--- 3 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 tests/unit/gemini-antigravity-pattern-preserved.test.ts diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index cb7c3d8fe86d..d784dbf8881d 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -10,7 +10,10 @@ export const GEMINI_UNSUPPORTED_SCHEMA_KEYS = new Set([ "maxLength", "exclusiveMinimum", "exclusiveMaximum", - "pattern", + // NOTE: `pattern` is intentionally NOT in this set. Antigravity (Gemini-derived + // surface) accepts `pattern` on string constraints, and glob/grep/file-search + // tools depend on it to express their argument regex. Removing it produced + // upstream 400s and wrong-tool semantics (decolua/9router#1368). "minItems", "maxItems", "format", diff --git a/tests/unit/gemini-antigravity-pattern-preserved.test.ts b/tests/unit/gemini-antigravity-pattern-preserved.test.ts new file mode 100644 index 000000000000..541ab428d42b --- /dev/null +++ b/tests/unit/gemini-antigravity-pattern-preserved.test.ts @@ -0,0 +1,54 @@ +// Regression test: `pattern` must be preserved in antigravity tool schemas. +// +// Upstream: decolua/9router @ f6c2f7ca / Fixes decolua/9router#1368. +// +// Tools such as glob/grep/file-search rely on a `pattern` constraint on +// string parameters. Antigravity (Gemini-derived) DOES accept `pattern`, +// so removing it for that surface drops critical tool semantics and +// produces upstream 400s / wrong-tool-call behavior. +// +// Two assertions, mirroring how the field is consumed downstream: +// 1) `pattern` must NOT live in the unsupported keyword set used by the +// antigravity schema cleaner. +// 2) End-to-end, `cleanJSONSchemaForAntigravity` must preserve a `pattern` +// constraint placed on a string property (typical glob/grep tool). + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + GEMINI_UNSUPPORTED_SCHEMA_KEYS, + cleanJSONSchemaForAntigravity, +} from "../../open-sse/translator/helpers/geminiHelper.ts"; + +test("GEMINI_UNSUPPORTED_SCHEMA_KEYS does not strip `pattern` for antigravity", () => { + assert.equal( + GEMINI_UNSUPPORTED_SCHEMA_KEYS.has("pattern"), + false, + "`pattern` is supported by antigravity and must be preserved on tool schemas" + ); +}); + +test("cleanJSONSchemaForAntigravity preserves `pattern` on glob/grep-style tool schema", () => { + const schema = { + type: "object", + properties: { + glob: { + type: "string", + description: "Glob pattern to match files", + pattern: "^[A-Za-z0-9_\\-/*.]+$", + }, + }, + required: ["glob"], + }; + + const cleaned = cleanJSONSchemaForAntigravity(schema) as { + properties: { glob: { pattern?: string } }; + }; + + assert.equal( + cleaned.properties.glob.pattern, + "^[A-Za-z0-9_\\-/*.]+$", + "`pattern` constraint on a string property must survive antigravity cleaning" + ); +}); diff --git a/tests/unit/translator-gemini-schema-pattern-property.test.ts b/tests/unit/translator-gemini-schema-pattern-property.test.ts index 85be361ce163..b5f9a869acc0 100644 --- a/tests/unit/translator-gemini-schema-pattern-property.test.ts +++ b/tests/unit/translator-gemini-schema-pattern-property.test.ts @@ -41,9 +41,10 @@ test("#1368: a property named 'pattern' survives Gemini schema sanitization", () ); }); -test("#1368: a string-level `pattern` CONSTRAINT is still stripped", () => { - // When `pattern` is an actual validation constraint on a string schema node, - // Gemini does not support it, so it must still be removed. +test("#1368: a string-level `pattern` CONSTRAINT is preserved for antigravity", () => { + // Antigravity (Gemini-derived) DOES accept `pattern` on string constraints. + // Stripping it broke glob/grep/file-search tools that express their argument + // regex via `pattern`. The sanitizer must keep it. (Ported from 9router @ f6c2f7ca.) const schema = { type: "object", properties: { @@ -58,8 +59,8 @@ test("#1368: a string-level `pattern` CONSTRAINT is still stripped", () => { assert.ok(cleaned.properties.code, "the `code` property itself survives"); assert.equal( - Object.prototype.hasOwnProperty.call(cleaned.properties.code, "pattern"), - false, - "the string `pattern` constraint must be stripped" + (cleaned.properties.code as { pattern?: string }).pattern, + "^[A-Z]{3}$", + "the string `pattern` constraint must be preserved for antigravity" ); }); From f4fa983a9f683971409625dd6689a4e542b3ec95 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:51:29 -0300 Subject: [PATCH 024/120] fix(perplexity): validate API keys via /v1/models endpoint (#4654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) --- .../providers/registry/perplexity/index.ts | 5 ++ .../perplexity-key-validation-models.test.ts | 50 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 tests/unit/perplexity-key-validation-models.test.ts diff --git a/open-sse/config/providers/registry/perplexity/index.ts b/open-sse/config/providers/registry/perplexity/index.ts index 97f1844ad911..14b90d413d3a 100644 --- a/open-sse/config/providers/registry/perplexity/index.ts +++ b/open-sse/config/providers/registry/perplexity/index.ts @@ -6,6 +6,11 @@ export const perplexityProvider: RegistryEntry = { format: "openai", executor: "default", baseUrl: "https://api.perplexity.ai/chat/completions", + // Perplexity deprecated the unversioned `/models` endpoint (returns 404), so + // pin an explicit `modelsUrl` here. Without it, validateOpenAILikeProvider + // (src/lib/providers/validation.ts) derives `/models` via + // addModelsSuffix and probes the dead endpoint, misclassifying valid keys. + modelsUrl: "https://api.perplexity.ai/v1/models", authType: "apikey", authHeader: "bearer", models: [ diff --git a/tests/unit/perplexity-key-validation-models.test.ts b/tests/unit/perplexity-key-validation-models.test.ts new file mode 100644 index 000000000000..ee078ea5cb90 --- /dev/null +++ b/tests/unit/perplexity-key-validation-models.test.ts @@ -0,0 +1,50 @@ +// Regression test for perplexity API key validation. +// +// Perplexity deprecated the unversioned `/models` endpoint (returns 404), so +// our default validation probe — which derives `/models` from the +// perplexity registry entry via `addModelsSuffix` — would always fail to +// confirm a valid key, falling through to the chat-completions probe and +// often misclassifying live keys as "Invalid". Inspired by upstream +// 9router fix (see commit message); we port it OmniRoute-style by +// declaring an explicit `modelsUrl` on the perplexity registry entry. + +import { describe, it } from "node:test"; +import assert from "node:assert"; + +describe("perplexity registry — key validation models endpoint", () => { + it("declares a modelsUrl pointing at /v1/models (not the deprecated /models)", async () => { + const { getRegistryEntry } = await import( + "../../open-sse/config/providerRegistry.ts" + ); + const entry = getRegistryEntry("perplexity"); + assert.ok(entry, "perplexity must be registered in the execution registry"); + assert.equal(entry.format, "openai"); + assert.ok( + typeof entry.modelsUrl === "string" && entry.modelsUrl.length > 0, + "perplexity registry entry must declare an explicit modelsUrl so key " + + "validation does not hit the deprecated /models endpoint" + ); + assert.equal( + entry.modelsUrl, + "https://api.perplexity.ai/v1/models", + "perplexity modelsUrl must point at the versioned /v1/models endpoint " + + "(unversioned /models was deprecated and now returns 404)" + ); + }); + + it("does not derive a /models URL that ends in /chat/completions/models", async () => { + const { getRegistryEntry } = await import( + "../../open-sse/config/providerRegistry.ts" + ); + const entry = getRegistryEntry("perplexity"); + assert.ok(entry?.modelsUrl, "modelsUrl required"); + assert.ok( + !entry.modelsUrl.includes("/chat/completions"), + "modelsUrl must not include /chat/completions" + ); + assert.ok( + entry.modelsUrl.endsWith("/v1/models"), + "modelsUrl must end with /v1/models" + ); + }); +}); From d2aa67a11cceb59e56416ff13809f2383df8d66a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:51:45 -0300 Subject: [PATCH 025/120] fix(image): prevent compatible nodes from shadowing provider aliases (#4656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) --- src/sse/services/model.ts | 106 ++++++++++++------ .../image-compat-node-alias-shadow.test.ts | 80 +++++++++++++ 2 files changed, 150 insertions(+), 36 deletions(-) create mode 100644 tests/unit/image-compat-node-alias-shadow.test.ts diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 0bb3588906e1..651694281fcc 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -7,9 +7,30 @@ import { resolveModelAliasFromMap, getModelInfoCore, } from "@omniroute/open-sse/services/model.ts"; +import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; export { parseModel }; +/** + * Reserved provider prefixes — built-in provider ids + aliases. User-defined + * compatible-node prefixes must not be allowed to shadow these, otherwise a + * node with prefix="cf" would hijack cloudflare-ai requests (and similar for + * every built-in provider). Ported from upstream 9router 047fdc89. + * + * Built lazily so the registry is only walked once per process. + */ +let _reservedProviderPrefixes: Set | null = null; +function getReservedProviderPrefixes(): Set { + if (_reservedProviderPrefixes) return _reservedProviderPrefixes; + const reserved = new Set(); + for (const entry of Object.values(REGISTRY)) { + if (entry?.id) reserved.add(entry.id); + if (entry?.alias) reserved.add(entry.alias); + } + _reservedProviderPrefixes = reserved; + return reserved; +} + /** * Build a combined model alias map that merges both alias stores: * 1. DB-namespace aliases (key_value WHERE namespace='modelAliases') — set via @@ -98,45 +119,58 @@ export async function getModelInfo(modelStr) { // Ensure prefixToCheck is always a concise identifier, not a full model string const prefixToCheck = parsed.providerAlias || parsed.provider; - // Check OpenAI Compatible nodes - // Match by node.prefix (user-defined alias) OR node.id (internal UUID id stored by - // combo steps), so that combo targets using the internal node id still resolve - // correctly (#2778). - const openaiNodes = await getProviderNodes({ type: "openai-compatible" }); - const matchedOpenAI = openaiNodes.find( - (node) => node.prefix === prefixToCheck || node.id === prefixToCheck - ); - if (matchedOpenAI) { - const { apiFormat, targetFormat } = await lookupCustomModelMeta( - matchedOpenAI.id as string, - parsed.model as string + // Compatible-node prefixes are user-defined. They must not be allowed to + // shadow built-in provider ids/aliases (e.g. `cf` → cloudflare-ai). When + // prefixToCheck matches a built-in registry id/alias, skip the compatible- + // node prefix lookup so the request still routes to the built-in provider. + // Internal UUID-prefixed node ids (e.g. "openai-compatible-responses-...") + // are never in the reserved set, so the #2778 combo path still works. + // Ported from upstream 9router 047fdc89. + const reserved = getReservedProviderPrefixes(); + const isReservedPrefix = + typeof prefixToCheck === "string" && reserved.has(prefixToCheck); + + if (!isReservedPrefix) { + // Check OpenAI Compatible nodes + // Match by node.prefix (user-defined alias) OR node.id (internal UUID id stored by + // combo steps), so that combo targets using the internal node id still resolve + // correctly (#2778). + const openaiNodes = await getProviderNodes({ type: "openai-compatible" }); + const matchedOpenAI = openaiNodes.find( + (node) => node.prefix === prefixToCheck || node.id === prefixToCheck ); - return { - provider: matchedOpenAI.id, - model: parsed.model, - extendedContext, - ...(apiFormat && { apiFormat }), - ...(targetFormat && { targetFormat }), - }; - } + if (matchedOpenAI) { + const { apiFormat, targetFormat } = await lookupCustomModelMeta( + matchedOpenAI.id as string, + parsed.model as string + ); + return { + provider: matchedOpenAI.id, + model: parsed.model, + extendedContext, + ...(apiFormat && { apiFormat }), + ...(targetFormat && { targetFormat }), + }; + } - // Check Anthropic Compatible nodes - const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" }); - const matchedAnthropic = anthropicNodes.find( - (node) => node.prefix === prefixToCheck || node.id === prefixToCheck - ); - if (matchedAnthropic) { - const { apiFormat, targetFormat } = await lookupCustomModelMeta( - matchedAnthropic.id as string, - parsed.model as string + // Check Anthropic Compatible nodes + const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" }); + const matchedAnthropic = anthropicNodes.find( + (node) => node.prefix === prefixToCheck || node.id === prefixToCheck ); - return { - provider: matchedAnthropic.id, - model: parsed.model, - extendedContext, - ...(apiFormat && { apiFormat }), - ...(targetFormat && { targetFormat }), - }; + if (matchedAnthropic) { + const { apiFormat, targetFormat } = await lookupCustomModelMeta( + matchedAnthropic.id as string, + parsed.model as string + ); + return { + provider: matchedAnthropic.id, + model: parsed.model, + extendedContext, + ...(apiFormat && { apiFormat }), + ...(targetFormat && { targetFormat }), + }; + } } // stripModelPrefix: if enabled, strip provider prefix and re-resolve diff --git a/tests/unit/image-compat-node-alias-shadow.test.ts b/tests/unit/image-compat-node-alias-shadow.test.ts new file mode 100644 index 000000000000..06d4d1db081e --- /dev/null +++ b/tests/unit/image-compat-node-alias-shadow.test.ts @@ -0,0 +1,80 @@ +/** + * Regression test — ported from decolua/9router commit 047fdc89: + * "fix(image): prevent compatible nodes from shadowing provider aliases" + * + * A user-defined openai-compatible provider node with `prefix: "cf"` must NOT + * shadow the built-in `cloudflare-ai` provider (alias `cf`). Cloudflare image + * routes like `cf/@cf/black-forest-labs/...` must keep resolving to the + * built-in cloudflare-ai provider, regardless of any compatible-node prefix + * collision. + * + * Inverse case: a non-reserved compatible-node prefix (e.g. `oct`) must still + * resolve to the compatible node. The fix is a reserved-prefix guard — it must + * not break user-defined prefixes that don't collide with built-in aliases. + */ +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-image-compat-shadow-") +); +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 { getModelInfo } = await import("../../src/sse/services/model.ts"); + +test.before(async () => { + await providersDb.createProviderNode({ + id: "openai-compatible-cf-collision", + type: "openai-compatible", + name: "Compatible CF Collision", + prefix: "cf", + baseUrl: "https://compatible.test/v1", + chatPath: "/v1/chat/completions", + modelsPath: "/v1/models", + }); + await providersDb.createProviderNode({ + id: "openai-compatible-oct-passthrough", + type: "openai-compatible", + name: "Compatible OCT", + prefix: "oct", + baseUrl: "https://compatible-oct.test/v1", + chatPath: "/v1/chat/completions", + modelsPath: "/v1/models", + }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("compatible node with prefix=cf must NOT shadow the built-in cloudflare-ai alias", async () => { + const info = (await getModelInfo("cf/@cf/black-forest-labs/flux-2-klein-9b")) as { + provider?: string; + model?: string; + }; + assert.equal( + info.provider, + "cloudflare-ai", + "cf/ must keep routing to the built-in cloudflare-ai provider (not the compatible node)" + ); + assert.equal(info.model, "@cf/black-forest-labs/flux-2-klein-9b"); +}); + +test("non-reserved compatible-node prefix (oct) still routes to the compatible node", async () => { + const info = (await getModelInfo("oct/gpt-image-1")) as { + provider?: string; + model?: string; + }; + assert.equal( + info.provider, + "openai-compatible-oct-passthrough", + "user-defined prefixes that don't collide with built-in aliases must still resolve to the compatible node" + ); + assert.equal(info.model, "gpt-image-1"); +}); From 1ad6c46b46586b81b8beafb940e8e0a010f1b34c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:51:54 -0300 Subject: [PATCH 026/120] fix(cli-tools): tolerate JSONC (comments, trailing commas) in tool settings (#4659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) --- src/app/api/cli-tools/_lib/jsoncConfig.ts | 51 +++++++ .../api/cli-tools/claude-settings/route.ts | 18 +-- src/app/api/cli-tools/cline-settings/route.ts | 28 ++-- src/app/api/cli-tools/droid-settings/route.ts | 17 +-- src/app/api/cli-tools/kilo-settings/route.ts | 16 +-- .../api/cli-tools/openclaw-settings/route.ts | 17 +-- tests/unit/cli-tools-settings-jsonc.test.ts | 127 ++++++++++++++++++ 7 files changed, 211 insertions(+), 63 deletions(-) create mode 100644 src/app/api/cli-tools/_lib/jsoncConfig.ts create mode 100644 tests/unit/cli-tools-settings-jsonc.test.ts diff --git a/src/app/api/cli-tools/_lib/jsoncConfig.ts b/src/app/api/cli-tools/_lib/jsoncConfig.ts new file mode 100644 index 000000000000..6141801737ab --- /dev/null +++ b/src/app/api/cli-tools/_lib/jsoncConfig.ts @@ -0,0 +1,51 @@ +/** + * Shared JSONC-tolerant config reader for CLI-tools settings routes. + * + * Background: several upstream CLI tools (opencode, kilo, droid, cline, etc.) + * ship config files that are JSON with the occasional trailing comma or a + * stray comment — valid JSONC, but `JSON.parse()` rejects them with a + * `SyntaxError`. Until this helper, every `readSettings`/`readConfig` helper + * only caught `ENOENT` and re-threw, surfacing as a 500 that the dashboard + * misread as "tool not installed". + * + * Behaviour: + * - strip trailing commas before parsing so JSONC files load cleanly; + * - on ANY read or parse failure, return the caller-supplied fallback + * (typically `null` or `{}`) instead of throwing, so the dashboard shows + * "installed but not configured" rather than "not installed". + * + * Ported from upstream `decolua/9router@6c10edf8`. Co-authored-by: Zireael. + */ +import { promises as fs } from "node:fs"; + +/** + * Parse a JSON/JSONC string, returning `null` on syntax errors instead of + * throwing. Trailing commas before `}` or `]` are stripped before parsing. + */ +export function parseJsoncOrNull(content: string): T | null { + try { + const stripped = content.replace(/,(\s*[}\]])/g, "$1"); + return JSON.parse(stripped) as T; + } catch { + return null; + } +} + +/** + * Read a JSON/JSONC config file. Returns `fallback` (default: `null`) on any + * filesystem or parse error so callers can render an "installed but not + * configured" state instead of crashing with a 500. + */ +export async function readJsoncConfig( + path: string, + fallback: T | null = null +): Promise { + let content: string; + try { + content = await fs.readFile(path, "utf-8"); + } catch { + return fallback; + } + const parsed = parseJsoncOrNull(content); + return parsed ?? fallback; +} diff --git a/src/app/api/cli-tools/claude-settings/route.ts b/src/app/api/cli-tools/claude-settings/route.ts index ab1163564487..1a3d735f3f25 100644 --- a/src/app/api/cli-tools/claude-settings/route.ts +++ b/src/app/api/cli-tools/claude-settings/route.ts @@ -15,22 +15,18 @@ import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db import { cliSettingsEnvSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { getApiKeyById } from "@/lib/localDb"; +import { readJsoncConfig } from "../_lib/jsoncConfig"; // Get claude settings path based on OS const getClaudeSettingsPath = () => getCliPrimaryConfigPath("claude"); -// Read current settings +// Read current settings. +// Ported from upstream decolua/9router@6c10edf8: tolerate JSONC (trailing +// commas) and return null on any parse error so the dashboard renders +// "installed but not configured" instead of a 500 misread as "not installed". const readSettings = async () => { - try { - const settingsPath = getClaudeSettingsPath(); - const content = await fs.readFile(settingsPath, "utf-8"); - return JSON.parse(content); - } catch (error: any) { - if (error.code === "ENOENT") { - return null; - } - throw error; - } + const settingsPath = getClaudeSettingsPath(); + return readJsoncConfig(settingsPath); }; // GET - Check claude CLI and read current settings diff --git a/src/app/api/cli-tools/cline-settings/route.ts b/src/app/api/cli-tools/cline-settings/route.ts index 090dcaf920a8..5b8dd37cd705 100644 --- a/src/app/api/cli-tools/cline-settings/route.ts +++ b/src/app/api/cli-tools/cline-settings/route.ts @@ -11,32 +11,20 @@ import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db import { cliModelConfigSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { resolveApiKey } from "@/shared/services/apiKeyResolver"; +import { readJsoncConfig } from "../_lib/jsoncConfig"; const CLINE_DATA_DIR = path.join(os.homedir(), ".cline", "data"); const GLOBAL_STATE_PATH = path.join(CLINE_DATA_DIR, "globalState.json"); const SECRETS_PATH = path.join(CLINE_DATA_DIR, "secrets.json"); -// Read globalState.json -const readGlobalState = async () => { - try { - const content = await fs.readFile(GLOBAL_STATE_PATH, "utf-8"); - return JSON.parse(content); - } catch (error: any) { - if (error.code === "ENOENT") return null; - throw error; - } -}; +// Read globalState.json. +// Ported from upstream decolua/9router@6c10edf8: tolerate JSONC (trailing +// commas) and return null on any parse error so the dashboard renders +// "installed but not configured" instead of a 500 misread as "not installed". +const readGlobalState = async () => readJsoncConfig(GLOBAL_STATE_PATH); -// Read secrets.json -const readSecrets = async () => { - try { - const content = await fs.readFile(SECRETS_PATH, "utf-8"); - return JSON.parse(content); - } catch (error: any) { - if (error.code === "ENOENT") return {}; - throw error; - } -}; +// Read secrets.json (same JSONC-tolerant behaviour; defaults to {} for compat). +const readSecrets = async () => readJsoncConfig>(SECRETS_PATH, {}); // Check if OmniRoute is configured as OpenAI-compatible provider const hasOmniRouteConfig = (globalState: any) => { diff --git a/src/app/api/cli-tools/droid-settings/route.ts b/src/app/api/cli-tools/droid-settings/route.ts index c7e15f42a054..7313faef818c 100644 --- a/src/app/api/cli-tools/droid-settings/route.ts +++ b/src/app/api/cli-tools/droid-settings/route.ts @@ -14,21 +14,16 @@ import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db import { cliModelConfigSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { resolveApiKey } from "@/shared/services/apiKeyResolver"; +import { readJsoncConfig } from "../_lib/jsoncConfig"; const getDroidSettingsPath = () => getCliPrimaryConfigPath("droid"); const getDroidDir = () => path.dirname(getDroidSettingsPath()); -// Read current settings.json -const readSettings = async () => { - try { - const settingsPath = getDroidSettingsPath(); - const content = await fs.readFile(settingsPath, "utf-8"); - return JSON.parse(content); - } catch (error: any) { - if (error.code === "ENOENT") return null; - throw error; - } -}; +// Read current settings.json. +// Ported from upstream decolua/9router@6c10edf8: tolerate JSONC (trailing +// commas) and return null on any parse error so the dashboard renders +// "installed but not configured" instead of a 500 misread as "not installed". +const readSettings = async () => readJsoncConfig(getDroidSettingsPath()); // Check if settings has OmniRoute customModels const hasOmniRouteConfig = (settings: any) => { diff --git a/src/app/api/cli-tools/kilo-settings/route.ts b/src/app/api/cli-tools/kilo-settings/route.ts index aac82bebd770..965ac03bead6 100644 --- a/src/app/api/cli-tools/kilo-settings/route.ts +++ b/src/app/api/cli-tools/kilo-settings/route.ts @@ -11,21 +11,17 @@ import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db import { cliModelConfigSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { resolveApiKey } from "@/shared/services/apiKeyResolver"; +import { readJsoncConfig } from "../_lib/jsoncConfig"; const KILO_DATA_DIR = path.join(os.homedir(), ".local", "share", "kilo"); const AUTH_PATH = path.join(KILO_DATA_DIR, "auth.json"); const KILO_CONFIG_DIR = path.join(os.homedir(), ".config", "kilo"); -// Read auth.json -const readAuth = async () => { - try { - const content = await fs.readFile(AUTH_PATH, "utf-8"); - return JSON.parse(content); - } catch (error) { - if (error.code === "ENOENT") return null; - throw error; - } -}; +// Read auth.json. +// Ported from upstream decolua/9router@6c10edf8: tolerate JSONC (trailing +// commas) and return null on any parse error so the dashboard renders +// "installed but not configured" instead of a 500 misread as "not installed". +const readAuth = async () => readJsoncConfig(AUTH_PATH); // Check if OmniRoute OpenAI-compatible provider is configured const hasOmniRouteConfig = (auth) => { diff --git a/src/app/api/cli-tools/openclaw-settings/route.ts b/src/app/api/cli-tools/openclaw-settings/route.ts index 3b6e37d0d409..19ca81880461 100644 --- a/src/app/api/cli-tools/openclaw-settings/route.ts +++ b/src/app/api/cli-tools/openclaw-settings/route.ts @@ -14,21 +14,16 @@ import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db import { cliModelConfigSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { resolveApiKey } from "@/shared/services/apiKeyResolver"; +import { readJsoncConfig } from "../_lib/jsoncConfig"; const getOpenClawSettingsPath = () => getCliPrimaryConfigPath("openclaw"); const getOpenClawDir = () => path.dirname(getOpenClawSettingsPath()); -// Read current settings.json -const readSettings = async () => { - try { - const settingsPath = getOpenClawSettingsPath(); - const content = await fs.readFile(settingsPath, "utf-8"); - return JSON.parse(content); - } catch (error: any) { - if (error.code === "ENOENT") return null; - throw error; - } -}; +// Read current settings.json. +// Ported from upstream decolua/9router@6c10edf8: tolerate JSONC (trailing +// commas) and return null on any parse error so the dashboard renders +// "installed but not configured" instead of a 500 misread as "not installed". +const readSettings = async () => readJsoncConfig(getOpenClawSettingsPath()); // Check if settings has OmniRoute config const hasOmniRouteConfig = (settings: any) => { diff --git a/tests/unit/cli-tools-settings-jsonc.test.ts b/tests/unit/cli-tools-settings-jsonc.test.ts new file mode 100644 index 000000000000..c259397ec4e1 --- /dev/null +++ b/tests/unit/cli-tools-settings-jsonc.test.ts @@ -0,0 +1,127 @@ +/** + * Regression test for the JSONC-tolerant config reader used by every + * cli-tools settings route. + * + * Ported from upstream `decolua/9router@6c10edf8`: + * "fix(cli-tools): tolerate JSONC configs in CLI tool settings routes". + * + * Before the fix, `readSettings`/`readConfig` helpers only caught `ENOENT` and + * re-threw on any other error — so a config file with a single trailing + * comma (valid JSONC, emitted by tools like opencode) crashed the GET route + * with a 500 and the dashboard misread the response as "tool not installed". + * + * After the fix: + * - trailing commas are stripped before parsing (JSONC tolerated); + * - any other parse error returns `null` (or the caller's fallback) so the + * dashboard renders "installed but not configured". + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const { parseJsoncOrNull, readJsoncConfig } = await import( + "../../src/app/api/cli-tools/_lib/jsoncConfig.ts" +); + +test("parseJsoncOrNull tolerates trailing commas in objects", () => { + const jsonc = `{ + "model": "gpt-5", + "tools": ["a", "b",], + "nested": { "x": 1, }, + }`; + const parsed = parseJsoncOrNull<{ model: string; tools: string[] }>(jsonc); + assert.ok(parsed, "parser must accept JSONC with trailing commas"); + assert.equal(parsed.model, "gpt-5"); + assert.deepEqual(parsed.tools, ["a", "b"]); +}); + +test("parseJsoncOrNull returns null on truly malformed JSON", () => { + assert.equal(parseJsoncOrNull("{ not json at all"), null); +}); + +test("readJsoncConfig returns fallback when file is missing", async () => { + const missing = path.join(os.tmpdir(), `cli-tools-jsonc-missing-${Date.now()}.json`); + assert.equal(await readJsoncConfig(missing), null); + assert.deepEqual(await readJsoncConfig(missing, {}), {}); +}); + +test("readJsoncConfig parses a JSONC file with trailing commas (regression)", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cli-tools-jsonc-")); + const file = path.join(dir, "settings.json"); + await fs.writeFile( + file, + `{ + "apiKey": "sk-test", + "model": "claude-sonnet-4-5", +} +`, + "utf-8" + ); + try { + const parsed = await readJsoncConfig<{ apiKey: string; model: string }>(file); + assert.ok(parsed, "JSONC file with trailing comma must NOT crash the reader"); + assert.equal(parsed.apiKey, "sk-test"); + assert.equal(parsed.model, "claude-sonnet-4-5"); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test("readJsoncConfig returns fallback on corrupted config instead of throwing", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cli-tools-jsonc-bad-")); + const file = path.join(dir, "settings.json"); + await fs.writeFile(file, "{ this is not valid json at all !!! ", "utf-8"); + try { + // Must NOT throw — the dashboard renders "installed but not configured" + // when this returns null, instead of "not installed" on a 500. + assert.equal(await readJsoncConfig(file), null); + assert.deepEqual(await readJsoncConfig(file, {}), {}); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +/** + * Source-guard: every cli-tools settings route's read helper must go through + * the JSONC-tolerant reader. A regression to raw `JSON.parse(content)` inside + * a `readSettings` / `readConfig` helper would re-introduce the original + * 500-on-JSONC bug, so we assert the routes do not contain that pattern. + * + * Same source-guard pattern as `tests/unit/source-guard-*.test.ts`. + */ +test("cli-tools settings routes use the JSONC-tolerant reader (source-guard)", async () => { + // For each route, look for the GET-read helper region (top of file, up to + // the first `export async function GET`) and assert it imports + uses the + // JSONC-tolerant reader instead of raw `JSON.parse(content)`. + const routes = [ + "claude-settings", + "cline-settings", + "droid-settings", + "kilo-settings", + "openclaw-settings", + ]; + const repoRoot = path.resolve(import.meta.dirname ?? ".", "..", ".."); + for (const r of routes) { + const src = await fs.readFile( + path.join(repoRoot, "src", "app", "api", "cli-tools", r, "route.ts"), + "utf-8" + ); + const getIdx = src.indexOf("export async function GET"); + assert.ok(getIdx > 0, `${r}: expected an exported GET handler`); + const head = src.slice(0, getIdx); + assert.ok( + /from\s+["']\.\.\/_lib\/jsoncConfig["']/.test(src), + `${r}: must import readJsoncConfig from ../_lib/jsoncConfig` + ); + assert.ok( + !/JSON\.parse\(\s*content\s*\)/.test(head), + `${r}: read helper still calls raw JSON.parse(content) — port the JSONC fix` + ); + assert.ok( + /readJsoncConfig\s*[<(]/.test(head), + `${r}: read helper must invoke readJsoncConfig` + ); + } +}); From 983e152ea27d001e19f8ea18b16b2c49100b21f1 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:52:21 -0300 Subject: [PATCH 027/120] fix(security): validate kiro region to prevent SSRF (GHSA-6mwv-4mrm-5p3m) (#4629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — kiro region SSRF guard (GHSA-6mwv-4mrm-5p3m), port rebuilt clean over release tip --- src/lib/oauth/constants/oauth.ts | 16 ++++ src/lib/oauth/providers/kiro.ts | 13 ++- src/lib/oauth/services/kiro.ts | 7 +- tests/unit/kiro-region-ssrf-guard.test.ts | 96 +++++++++++++++++++++++ 4 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 tests/unit/kiro-region-ssrf-guard.test.ts diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index f415963fa6cc..4d33ce42296d 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -262,6 +262,22 @@ export const GITLAB_DUO_CONFIG = { codeChallengeMethod: "S256", }; +// AWS region allowlist — prevents SSRF via region injection into upstream URLs +// (GHSA-6mwv-4mrm-5p3m). Region values flow user-supplied through the kiro OAuth +// import surfaces (request body, providerSpecificData) and are interpolated into +// URLs like `https://oidc.${region}.amazonaws.com/...`. Without this guard, a +// region like "127.0.0.1" or "evil.com" would redirect the proxy's outbound +// fetch to an attacker-controlled host. Canonical AWS region shape only: +// two letters, dash, one-or-more letters, dash, one-or-two digits. +export const AWS_REGION_PATTERN = /^[a-z]{2}-[a-z]+-\d{1,2}$/; + +export function assertValidAwsRegion(region: string): string { + if (typeof region !== "string" || !AWS_REGION_PATTERN.test(region)) { + throw new Error("Invalid region"); + } + return region; +} + // Kiro OAuth Configuration // Supports multiple auth methods: // 1. AWS Builder ID (Device Code Flow) diff --git a/src/lib/oauth/providers/kiro.ts b/src/lib/oauth/providers/kiro.ts index 63d996511472..2efb7fc6095a 100644 --- a/src/lib/oauth/providers/kiro.ts +++ b/src/lib/oauth/providers/kiro.ts @@ -1,11 +1,16 @@ -import { KIRO_CONFIG } from "../constants/oauth"; +import { KIRO_CONFIG, AWS_REGION_PATTERN, assertValidAwsRegion } from "../constants/oauth"; export const kiro = { config: KIRO_CONFIG, flowType: "device_code", requestDeviceCode: async (config) => { const regionMatch = String(config.tokenUrl || "").match(/oidc\.([a-z0-9-]+)\.amazonaws\.com/i); - const resolvedRegion = regionMatch?.[1] || "us-east-1"; + const candidateRegion = regionMatch?.[1] || "us-east-1"; + // Region is sourced from KIRO_CONFIG.tokenUrl (trusted constant) but defensively + // re-validate before letting it influence later fetches (GHSA-6mwv-4mrm-5p3m). + const resolvedRegion = AWS_REGION_PATTERN.test(candidateRegion) + ? candidateRegion + : "us-east-1"; const registerPayload: { clientName: string; clientType: string; @@ -77,6 +82,7 @@ export const kiro = { }, pollToken: async (config, deviceCode, codeVerifier, extraData) => { const tokenRegion = String(extraData?._region || "us-east-1").toLowerCase(); + assertValidAwsRegion(tokenRegion); const tokenUrl = `https://oidc.${tokenRegion}.amazonaws.com/token`; const response = await fetch(tokenUrl, { @@ -132,6 +138,9 @@ export const kiro = { const accessToken = tokenData?.access_token; if (!accessToken) return null; const region = String(tokenData?._region || "us-east-1").toLowerCase(); + // Defensive: tokenData._region came from upstream JSON or extraData + // and is interpolated into the runtime host below (GHSA-6mwv-4mrm-5p3m). + if (!AWS_REGION_PATTERN.test(region)) return null; const runtimeHost = region === "us-east-1" ? "https://codewhisperer.us-east-1.amazonaws.com" diff --git a/src/lib/oauth/services/kiro.ts b/src/lib/oauth/services/kiro.ts index e3ca235de343..b1357533f3e0 100644 --- a/src/lib/oauth/services/kiro.ts +++ b/src/lib/oauth/services/kiro.ts @@ -1,4 +1,4 @@ -import { KIRO_CONFIG } from "../constants/oauth"; +import { KIRO_CONFIG, assertValidAwsRegion } from "../constants/oauth"; /** * Kiro OAuth Service @@ -17,6 +17,7 @@ export class KiroService { * Returns clientId and clientSecret for device code flow */ async registerClient(region: string = "us-east-1") { + assertValidAwsRegion(region); const endpoint = `https://oidc.${region}.amazonaws.com/client/register`; const response = await fetch(endpoint, { @@ -55,6 +56,7 @@ export class KiroService { startUrl: string, region: string = "us-east-1" ) { + assertValidAwsRegion(region); const endpoint = `https://oidc.${region}.amazonaws.com/device_authorization`; const response = await fetch(endpoint, { @@ -94,6 +96,7 @@ export class KiroService { deviceCode: string, region: string = "us-east-1" ) { + assertValidAwsRegion(region); const endpoint = `https://oidc.${region}.amazonaws.com/token`; const response = await fetch(endpoint, { @@ -189,6 +192,7 @@ export class KiroService { // but a Kiro-social refresh token the OIDC client can't refresh — use the social path (#2467). if (clientId && clientSecret && authMethod !== "imported") { const resolvedRegion = region || "us-east-1"; + assertValidAwsRegion(resolvedRegion); const endpoint = `https://oidc.${resolvedRegion}.amazonaws.com/token`; const response = await fetch(endpoint, { @@ -288,6 +292,7 @@ export class KiroService { * If registerClient() also fails, the import falls back to the shared social-auth refresh path. */ async validateImportToken(refreshToken: string, region: string = "us-east-1") { + assertValidAwsRegion(region); // Validate token format if (!refreshToken.startsWith("aorAAAAAG")) { throw new Error("Invalid token format. Token should start with aorAAAAAG..."); diff --git a/tests/unit/kiro-region-ssrf-guard.test.ts b/tests/unit/kiro-region-ssrf-guard.test.ts new file mode 100644 index 000000000000..57adc8015aa1 --- /dev/null +++ b/tests/unit/kiro-region-ssrf-guard.test.ts @@ -0,0 +1,96 @@ +// SSRF regression guard for kiro region — GHSA-6mwv-4mrm-5p3m. +// Without the assertValidAwsRegion guard, a malicious region value +// would be interpolated directly into upstream URLs like +// `https://oidc.${region}.amazonaws.com/token`, allowing the caller to +// redirect the proxy to arbitrary hosts (file://, 127.0.0.1, EC2 metadata, etc). +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + AWS_REGION_PATTERN, + assertValidAwsRegion, +} from "../../src/lib/oauth/constants/oauth"; +import { KiroService } from "../../src/lib/oauth/services/kiro"; + +describe("AWS_REGION_PATTERN", () => { + it("accepts canonical AWS regions", () => { + for (const r of [ + "us-east-1", + "us-west-2", + "eu-west-1", + "ap-southeast-2", + "ca-central-1", + "sa-east-1", + "me-south-1", + "af-south-1", + "eu-central-2", + ]) { + assert.ok(AWS_REGION_PATTERN.test(r), `expected ${r} to match`); + } + }); + + it("rejects SSRF-shaped values", () => { + for (const bad of [ + "127.0.0.1", + "169.254.169.254", + "localhost", + "evil.example.com", + "us-east-1.evil.com", + "us-east-1/../foo", + "us-east-1#frag", + "us-east-1?x=1", + "file:///etc/passwd", + "http://internal", + "", + "US-EAST-1", // wrong case + "us_east_1", + "us-east-", + "-east-1", + "us--east-1", + ]) { + assert.ok(!AWS_REGION_PATTERN.test(bad), `expected ${bad!} to be rejected`); + } + }); +}); + +describe("assertValidAwsRegion", () => { + it("returns the region when valid", () => { + assert.equal(assertValidAwsRegion("us-east-1"), "us-east-1"); + }); + + it("throws on non-string", () => { + assert.throws(() => assertValidAwsRegion(undefined as unknown as string)); + assert.throws(() => assertValidAwsRegion(null as unknown as string)); + assert.throws(() => assertValidAwsRegion(123 as unknown as string)); + }); + + it("throws on invalid region", () => { + assert.throws(() => assertValidAwsRegion("127.0.0.1")); + assert.throws(() => assertValidAwsRegion("evil.com")); + assert.throws(() => assertValidAwsRegion("")); + }); +}); + +describe("KiroService SSRF guard", () => { + const svc = new KiroService(); + + it("registerClient rejects malicious region", async () => { + await assert.rejects(() => svc.registerClient("127.0.0.1")); + await assert.rejects(() => svc.registerClient("evil.example.com")); + }); + + it("startDeviceAuthorization rejects malicious region", async () => { + await assert.rejects(() => + svc.startDeviceAuthorization("cid", "csec", "https://x", "169.254.169.254") + ); + }); + + it("pollDeviceToken rejects malicious region", async () => { + await assert.rejects(() => + svc.pollDeviceToken("cid", "csec", "dc", "127.0.0.1") + ); + }); + + it("validateImportToken rejects malicious region before fetching", async () => { + await assert.rejects(() => svc.validateImportToken("aorAAAAAGfoo", "127.0.0.1")); + }); +}); From e1767743959f8e4a29f5c29e9fd7179d48a563b1 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:52:52 -0300 Subject: [PATCH 028/120] fix(cli): harden the systray2 tray runtime (port of 9router#1080) (#4628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — port rebuilt clean over release tip, release-green validated --- bin/cli/runtime/trayRuntime.ts | 54 +++++++++++++++++--- bin/cli/tray/tray.ts | 8 ++- tests/unit/cli-tray-systray2.test.ts | 75 ++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 tests/unit/cli-tray-systray2.test.ts diff --git a/bin/cli/runtime/trayRuntime.ts b/bin/cli/runtime/trayRuntime.ts index c1d76a96fde9..cc734b6f8066 100644 --- a/bin/cli/runtime/trayRuntime.ts +++ b/bin/cli/runtime/trayRuntime.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { execSync } from "node:child_process"; @@ -6,7 +6,43 @@ import { execSync } from "node:child_process"; const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime"); // systray2 is a maintained fork with prebuilt binaries — installed lazily at runtime, // not in dependencies, to avoid npm install overhead for users who don't use --tray. -const SYSTRAY_VERSION = "systray2@1.4.5"; +// +// Pin: stay on the 2.x line. The original `systray@1.0.5` package bundles a 2017 +// x86_64 Go binary whose Mach-O headers modern dyld (macOS 14+) rejects, so the +// tray silently fails to register on Apple Silicon. systray2 2.x ships newer +// getlantern/systray-portable binaries that work under Rosetta. Inherited from +// upstream decolua/9router#1080. +export const SYSTRAY_PACKAGE = "systray2"; +export const SYSTRAY_VERSION = "2.1.4"; +const SYSTRAY_SPEC = `${SYSTRAY_PACKAGE}@${SYSTRAY_VERSION}`; + +export function resolveSystrayBinName(platform: NodeJS.Platform): string | null { + if (platform === "win32") return null; + if (platform === "darwin") return "tray_darwin_release"; + return "tray_linux_release"; +} + +export interface ChmodResult { + changed: boolean; + reason?: "win32-skip" | "missing" | "chmod-failed"; +} + +// systray2's npm tarball sometimes ships the bundled Go binary without the +// executable bit set on macOS/Linux, causing spawn() to fail with EACCES. +// Set +x best-effort so the tray actually starts. Inherited from +// upstream decolua/9router#1080. +export function chmodSystrayBinAt(runtimeRoot: string, platform: NodeJS.Platform): ChmodResult { + const binName = resolveSystrayBinName(platform); + if (!binName) return { changed: false, reason: "win32-skip" }; + const binPath = join(runtimeRoot, "node_modules", SYSTRAY_PACKAGE, "traybin", binName); + if (!existsSync(binPath)) return { changed: false, reason: "missing" }; + try { + chmodSync(binPath, 0o755); + return { changed: true }; + } catch { + return { changed: false, reason: "chmod-failed" }; + } +} export async function loadSystray(): Promise<(new (...args: unknown[]) => unknown) | null> { if (process.platform === "win32") return null; // Windows uses tray.ps1 instead @@ -15,15 +51,21 @@ export async function loadSystray(): Promise<(new (...args: unknown[]) => unknow try { installSystray(); } catch (err) { + // Surface failures to stderr instead of staying silent — anyone hitting + // a tray problem otherwise has zero diagnostic. (PR #1080) console.warn(`[omniroute] tray runtime install failed: ${(err as Error).message}`); return null; } } + // Best-effort: ensure the bundled Go binary is executable. Some npm tarballs + // drop the +x bit on extraction (observed on macOS). + chmodSystrayBinAt(RUNTIME_DIR, process.platform); try { - const modPath = join(RUNTIME_DIR, "node_modules", "systray2"); + const modPath = join(RUNTIME_DIR, "node_modules", SYSTRAY_PACKAGE); const mod = await import(modPath); return (mod.default ?? mod.SysTray ?? mod) as (new (...args: unknown[]) => unknown) | null; - } catch { + } catch (err) { + console.warn(`[omniroute] tray runtime import failed: ${(err as Error).message}`); return null; } } @@ -37,12 +79,12 @@ function ensureRuntimeDir(): void { } function isInstalled(): boolean { - return existsSync(join(RUNTIME_DIR, "node_modules", "systray2", "package.json")); + return existsSync(join(RUNTIME_DIR, "node_modules", SYSTRAY_PACKAGE, "package.json")); } function installSystray(): void { execSync( - `npm install --prefix "${RUNTIME_DIR}" ${SYSTRAY_VERSION} --no-audit --no-fund --silent`, + `npm install --prefix "${RUNTIME_DIR}" ${SYSTRAY_SPEC} --no-audit --no-fund --silent`, { stdio: ["ignore", "ignore", "pipe"], timeout: 120_000 } ); } diff --git a/bin/cli/tray/tray.ts b/bin/cli/tray/tray.ts index f9e3c642e194..502467bc9228 100644 --- a/bin/cli/tray/tray.ts +++ b/bin/cli/tray/tray.ts @@ -124,6 +124,10 @@ async function initUnixTray(options: TrayOptions): Promise const systray = new SysTray({ menu: { icon: getIconBase64(), + // isTemplateIcon: false on darwin — the bundled icon.png is a full-color + // RGBA logo; template mode would render it as a solid white square + // because macOS template icons only use the alpha channel. (PR #1080) + isTemplateIcon: false, title: "OmniRoute", tooltip: `OmniRoute :${options.port}`, items: menuItems.map((it) => ({ @@ -175,6 +179,8 @@ async function initUnixTray(options: TrayOptions): Promise setTooltip: () => { /* systray2 does not support runtime tooltip change */ }, - destroy: () => systray.kill(), + // Pass false so systray2's kill does NOT call process.exit(0) before the + // rest of cleanup (server SIGKILL, MITM/tunnel cleanup) runs. (PR #1080) + destroy: () => systray.kill(false), }; } diff --git a/tests/unit/cli-tray-systray2.test.ts b/tests/unit/cli-tray-systray2.test.ts new file mode 100644 index 000000000000..931e926bb834 --- /dev/null +++ b/tests/unit/cli-tray-systray2.test.ts @@ -0,0 +1,75 @@ +// Regression coverage for the systray2 runtime swap (port of upstream PR #1080). +// The bug upstream: the bundled Go binary in node_modules/systray2/traybin +// sometimes ships without the executable bit on macOS/Linux, causing the tray +// to spawn() with EACCES and the icon never to appear. +// +// We can't load the real native binary in unit tests, but we can verify the +// pure helpers that drive the swap: the package + version pin and the +// chmod helper's behavior on a synthetic binary tree. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, statSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + SYSTRAY_PACKAGE, + SYSTRAY_VERSION, + chmodSystrayBinAt, + resolveSystrayBinName, +} from "../../bin/cli/runtime/trayRuntime.ts"; + +test("systray2 is pinned to a 2.x version (PR #1080 fix)", () => { + assert.equal(SYSTRAY_PACKAGE, "systray2"); + // The original systray@1.0.5 binary is incompatible with modern macOS dyld; + // upstream PR #1080 moved to the systray2 2.x line. Pin must stay ≥ 2.x. + assert.match(SYSTRAY_VERSION, /^2\./, `expected systray2@2.x, got ${SYSTRAY_VERSION}`); +}); + +test("resolveSystrayBinName returns null on win32 and a *_release name elsewhere", () => { + assert.equal(resolveSystrayBinName("win32"), null); + assert.equal(resolveSystrayBinName("darwin"), "tray_darwin_release"); + assert.equal(resolveSystrayBinName("linux"), "tray_linux_release"); +}); + +test("chmodSystrayBinAt sets +x on the bundled tray binary when present", () => { + const root = mkdtempSync(join(tmpdir(), "omniroute-systray-bin-")); + try { + const platform = process.platform === "win32" ? "linux" : process.platform; + const binName = resolveSystrayBinName(platform)!; + const binDir = join(root, "node_modules", "systray2", "traybin"); + mkdirSync(binDir, { recursive: true }); + const binPath = join(binDir, binName); + writeFileSync(binPath, "#!/bin/sh\nexit 0\n", { mode: 0o644 }); + chmodSync(binPath, 0o644); // explicit: not executable + const result = chmodSystrayBinAt(root, platform); + assert.equal(result.changed, true, "expected chmodSystrayBinAt to chmod the bin"); + // 0o111 covers the three execute bits — assert at least one is set. + const mode = statSync(binPath).mode & 0o777; + assert.ok((mode & 0o111) !== 0, `expected exec bits on bin, got mode=${mode.toString(8)}`); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("chmodSystrayBinAt is a no-op when the binary doesn't exist", () => { + const root = mkdtempSync(join(tmpdir(), "omniroute-systray-bin-")); + try { + const result = chmodSystrayBinAt(root, "linux"); + assert.equal(result.changed, false); + assert.equal(result.reason, "missing"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("chmodSystrayBinAt skips win32 (uses PowerShell tray, no Go binary)", () => { + const root = mkdtempSync(join(tmpdir(), "omniroute-systray-bin-")); + try { + const result = chmodSystrayBinAt(root, "win32"); + assert.equal(result.changed, false); + assert.equal(result.reason, "win32-skip"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); From 32b2df974a1bf2561a964d9217aee9b0eb94ef64 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:54:43 -0300 Subject: [PATCH 029/120] fix(test): validate anthropic-compatible connections via POST /v1/messages (#4657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — anthropic-compat validation via POST /v1/messages (port 584cf66a), rebuilt clean + baseline; release-green --- config/quality/file-size-baseline.json | 2 +- src/lib/providers/validation.ts | 13 +-- ...opic-compat-validation-v1-messages.test.ts | 80 +++++++++++++++++++ .../unit/provider-validation-branches.test.ts | 10 ++- 4 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 tests/unit/anthropic-compat-validation-v1-messages.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 1b9400338999..f0e4848b59d7 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -201,7 +201,7 @@ "src/lib/evals/evalRunner.ts": 961, "src/lib/memory/retrieval.ts": 1171, "src/lib/modelsDevSync.ts": 934, - "src/lib/providers/validation.ts": 4522, + "src/lib/providers/validation.ts": 4523, "src/lib/tailscaleTunnel.ts": 1202, "src/lib/usage/callLogs.ts": 975, "src/lib/usage/providerLimits.ts": 950, diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 2578bc911e3c..13040f3b8b34 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -2376,7 +2376,12 @@ async function validateAnthropicCompatibleProvider({ providerSpecificData ); - // Step 1: Try GET /models + // Step 1: Best-effort GET /models probe. /models is NOT part of the Anthropic API spec + // and many compatible proxies either 404, 401, or 403 on /models even with a valid key — + // so a 401/403 here must NOT mark the credentials invalid. Only a 2xx is a positive + // signal that the proxy DOES implement /models AND the key was accepted; everything else + // (including auth-shaped statuses) falls through to the authoritative POST /v1/messages + // probe below. Ported from decolua/9router 584cf66a. try { const modelsRes = await validationRead( joinBaseUrlAndPath(baseUrl, providerSpecificData?.modelsPath || "/models"), @@ -2390,15 +2395,11 @@ async function validateAnthropicCompatibleProvider({ if (modelsRes.ok) { return { valid: true, error: null }; } - - if (modelsRes.status === 401 || modelsRes.status === 403) { - return { valid: false, error: "Invalid API key" }; - } } catch { // /models fetch failed — fall through to messages test } - // Step 2: Fallback — try a minimal messages request + // Step 2: Authoritative probe — POST /v1/messages with max_tokens=1. const testModelId = providerSpecificData?.validationModelId || "claude-3-5-sonnet-20241022"; try { const messagesRes = await validationWrite( diff --git a/tests/unit/anthropic-compat-validation-v1-messages.test.ts b/tests/unit/anthropic-compat-validation-v1-messages.test.ts new file mode 100644 index 000000000000..2ef51cf7263a --- /dev/null +++ b/tests/unit/anthropic-compat-validation-v1-messages.test.ts @@ -0,0 +1,80 @@ +// Regression test for the anthropic-compatible connection validator. +// +// Upstream fix: GET /models is not part of the Anthropic API spec; many +// compatible proxies either 404, 401, or 403 on /models even with a valid +// API key. The connection test must therefore not reject the credentials +// solely on a 401/403 from /models — it must fall back to POST /v1/messages +// (the canonical Anthropic auth probe) and treat any non-401/403 messages +// response as proof that the key was accepted. +// +// Ported from decolua/9router 584cf66a (Co-author: Rehan Choirul). + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test( + "anthropic-compatible validation falls back to /messages when /models returns 403", + async () => { + const calls: { url: string; method: string }[] = []; + globalThis.fetch = async (url: any, init: any = {}) => { + const u = String(url); + const method = String(init?.method || "GET").toUpperCase(); + calls.push({ url: u, method }); + if (u.endsWith("/models")) { + return new Response(JSON.stringify({ error: "forbidden on models" }), { status: 403 }); + } + // /messages: upstream accepts the key but rejects the toy payload with 400. + return new Response(JSON.stringify({ error: "bad request" }), { status: 400 }); + }; + + const result = await validateProviderApiKey({ + provider: "anthropic-compatible-403-on-models", + apiKey: "sk-test", + providerSpecificData: { baseUrl: "https://proxy.example.com/v1/messages" }, + }); + + // BEFORE the fix this returned { valid: false, error: "Invalid API key" } + // because validateAnthropicCompatibleProvider short-circuited on the 403 + // from GET /models without ever probing POST /v1/messages. + assert.equal(result.valid, true, "403 on /models alone must NOT mark the key invalid"); + assert.equal(result.error, null); + + // The validator must actually exercise the messages endpoint. + const messagesCall = calls.find( + (call) => call.url.endsWith("/messages") && call.method === "POST" + ); + assert.ok(messagesCall, "expected a POST /messages probe after /models 403"); + } +); + +test( + "anthropic-compatible validation still rejects when /messages itself returns 401", + async () => { + // Symmetry guard: the fix must NOT make every 403/401 pass. Only the + // messages probe is authoritative — if it also rejects auth, the key is bad. + globalThis.fetch = async (url: any) => { + const u = String(url); + if (u.endsWith("/models")) { + return new Response(JSON.stringify({ error: "no models endpoint" }), { status: 403 }); + } + return new Response(JSON.stringify({ error: "invalid_api_key" }), { status: 401 }); + }; + + const result = await validateProviderApiKey({ + provider: "anthropic-compatible-truly-bad-key", + apiKey: "sk-bad", + providerSpecificData: { baseUrl: "https://proxy.example.com/v1/messages" }, + }); + + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); + } +); diff --git a/tests/unit/provider-validation-branches.test.ts b/tests/unit/provider-validation-branches.test.ts index a54b8aaa5cc5..9cd98eb72ee7 100644 --- a/tests/unit/provider-validation-branches.test.ts +++ b/tests/unit/provider-validation-branches.test.ts @@ -168,7 +168,10 @@ test("anthropic-compatible validation requires a base URL", async () => { assert.match(result.error, /No base URL configured/i); }); -test("anthropic-compatible validation rejects invalid keys from /models", async () => { +test("anthropic-compatible validation rejects invalid keys (auth-fail on both /models and /messages)", async () => { + // After the 584cf66a port, /models alone is not authoritative — many compatible + // proxies 401/403 on /models even with a valid key. To prove the key is bad we + // require an auth-shaped failure on POST /v1/messages too. const calls = []; globalThis.fetch = async (url) => { calls.push(String(url)); @@ -183,7 +186,10 @@ test("anthropic-compatible validation rejects invalid keys from /models", async assert.equal(result.valid, false); assert.equal(result.error, "Invalid API key"); - assert.deepEqual(calls, ["https://api.example.com/v1/models"]); + assert.deepEqual(calls, [ + "https://api.example.com/v1/models", + "https://api.example.com/v1/messages", + ]); }); test("anthropic-compatible validation falls back to /messages and treats 400 as auth success", async () => { From 607ad12e842cd021f9cb0a1107d4b2fd280eed41 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:56:07 -0300 Subject: [PATCH 030/120] fix(executors): strip params unsupported by the target provider/model (#4658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — port rebuilt clean over release tip, release-green validated --- open-sse/executors/default.ts | 12 ++ open-sse/executors/github.ts | 8 ++ open-sse/translator/paramSupport.ts | 60 +++++++++ ...executors-strip-unsupported-params.test.ts | 121 ++++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 open-sse/translator/paramSupport.ts create mode 100644 tests/unit/executors-strip-unsupported-params.test.ts diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 034477ec48af..0eceec4530e2 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -16,6 +16,7 @@ import { getGigachatAccessToken } from "../services/gigachatAuth.ts"; import { getRegistryEntry } from "../config/providerRegistry.ts"; import { mergeClientAnthropicBeta } from "../config/anthropicHeaders.ts"; import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; +import { stripUnsupportedParams } from "../translator/paramSupport.ts"; import { detectFormat, getOpenAICompatibleType, @@ -651,6 +652,17 @@ export class DefaultExecutor extends BaseExecutor { ); } + // Config-driven strip of params unsupported by the target provider/model + // (e.g. claude-opus-4 deprecated `temperature` → Anthropic 400). Port from + // 9router#7ae9fff6 (fixes upstream #1748). Rules live in + // ../translator/paramSupport.ts so adding one means editing one table. + if (typeof withDefaults === "object" && withDefaults !== null) { + const bodyRecord = withDefaults as Record; + const outboundModel = + typeof bodyRecord.model === "string" ? bodyRecord.model : model; + stripUnsupportedParams(this.provider, outboundModel, bodyRecord); + } + // Apply modelIdPrefix from RegistryEntry (e.g. "accounts/fireworks/models/") // so registry can store short model IDs while the upstream API receives the full path. if (typeof withDefaults === "object" && withDefaults !== null) { diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index eadc46d90eb2..f4ab762f800e 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -6,6 +6,7 @@ import { getGitHubCopilotRefreshHeaders, } from "../config/providerHeaderProfiles.ts"; import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; +import { stripUnsupportedParams } from "../translator/paramSupport.ts"; export class GithubExecutor extends BaseExecutor { constructor() { @@ -126,6 +127,13 @@ export class GithubExecutor extends BaseExecutor { ); } + // Config-driven strip of params unsupported by the target provider/model. + // For GitHub Copilot this removes Claude-style `thinking` and + // `reasoning_effort` for Claude models that reject them upstream + // (Haiku 4.5 / Opus 4.7 — Opus 4.6 / Sonnet 4.6 keep them). + // Port from 9router#7ae9fff6 (fixes upstream #1748, #713). + stripUnsupportedParams("github", model, modifiedBody); + return modifiedBody; } diff --git a/open-sse/translator/paramSupport.ts b/open-sse/translator/paramSupport.ts new file mode 100644 index 000000000000..142f5528bbf7 --- /dev/null +++ b/open-sse/translator/paramSupport.ts @@ -0,0 +1,60 @@ +// Strip request params a given provider/model rejects upstream (e.g. HTTP 400). +// Config-driven: add a rule here instead of scattering `delete body.x` across +// executors. Port from 9router#7ae9fff6 (fixes upstream #1748). +// +// Rule semantics: +// - `provider` (optional) limits the rule to a single provider id. +// - `match` is a RegExp tested against the model id OR a predicate (model -> boolean). +// - `drop` is the list of param keys to remove when the rule fires. +// +// A param is removed only when it is present (!== undefined). The helper never +// introduces new keys and never throws on null/undefined bodies — call sites +// can chain it without extra guards. + +type StripRule = { + provider?: string; + match: RegExp | ((model: string) => boolean); + drop: string[]; +}; + +const STRIP_RULES: StripRule[] = [ + // claude-opus-4 series: temperature is deprecated (Anthropic returns 400). #1748 + { match: /claude-opus-4/i, drop: ["temperature"] }, + // GitHub Copilot gpt-5.4: temperature unsupported. + { provider: "github", match: /gpt-5\.4/i, drop: ["temperature"] }, + // GitHub Copilot Claude (except opus/sonnet 4.6): thinking + reasoning_effort rejected. #713 + { + provider: "github", + match: (m: string) => + /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), + drop: ["thinking", "reasoning_effort"], + }, +]; + +function matches(rule: StripRule, model: string): boolean { + return typeof rule.match === "function" ? rule.match(model) : rule.match.test(model); +} + +/** + * Remove unsupported params from `body` in place. Returns the same reference + * (or `body` unchanged when it is not a plain object / model is empty). + */ +export function stripUnsupportedParams( + provider: string | null | undefined, + model: string | null | undefined, + body: T +): T { + if (!model || !body || typeof body !== "object") return body; + const rec = body as unknown as Record; + for (const rule of STRIP_RULES) { + if (rule.provider && rule.provider !== provider) continue; + if (!matches(rule, model)) continue; + for (const key of rule.drop) { + if (rec[key] !== undefined) delete rec[key]; + } + } + return body; +} + +// Exported for unit tests only — do not import from production code. +export const __STRIP_RULES_FOR_TEST: ReadonlyArray = STRIP_RULES; diff --git a/tests/unit/executors-strip-unsupported-params.test.ts b/tests/unit/executors-strip-unsupported-params.test.ts new file mode 100644 index 000000000000..d1896408c4df --- /dev/null +++ b/tests/unit/executors-strip-unsupported-params.test.ts @@ -0,0 +1,121 @@ +// Unit tests for stripUnsupportedParams: per-(provider,model) strip of params +// the upstream rejects (HTTP 400). Port from 9router#7ae9fff6 (fixes #1748). +// +// Rule coverage: +// 1. claude-opus-4 series: temperature deprecated → Anthropic 400. +// 2. github + gpt-5.4: temperature unsupported. +// 3. github + Claude (except opus/sonnet 4.6): thinking + reasoning_effort rejected. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + stripUnsupportedParams, + __STRIP_RULES_FOR_TEST, +} from "../../open-sse/translator/paramSupport.ts"; + +test("stripUnsupportedParams: drops temperature for claude-opus-4 models (any provider)", () => { + const body = { model: "claude-opus-4-20250514", temperature: 0.7, max_tokens: 100 }; + stripUnsupportedParams("anthropic", "claude-opus-4-20250514", body); + assert.equal(body.temperature, undefined, "temperature must be stripped"); + assert.equal(body.max_tokens, 100, "other params must survive"); + assert.equal(body.model, "claude-opus-4-20250514", "model must not be touched"); +}); + +test("stripUnsupportedParams: drops temperature for claude-opus-4-1 (case insensitive)", () => { + const body: Record = { temperature: 0.5 }; + stripUnsupportedParams("anthropic", "CLAUDE-OPUS-4-1-20250805", body); + assert.equal(body.temperature, undefined); +}); + +test("stripUnsupportedParams: keeps temperature for claude-sonnet-4 (only opus-4 affected)", () => { + const body: Record = { temperature: 0.7 }; + stripUnsupportedParams("anthropic", "claude-sonnet-4-20250514", body); + assert.equal(body.temperature, 0.7, "sonnet-4 still accepts temperature"); +}); + +test("stripUnsupportedParams: keeps temperature for claude-opus-3 (regression guard)", () => { + const body: Record = { temperature: 0.7 }; + stripUnsupportedParams("anthropic", "claude-3-opus-20240229", body); + assert.equal(body.temperature, 0.7); +}); + +test("stripUnsupportedParams: github + gpt-5.4 strips temperature", () => { + const body: Record = { temperature: 1, max_completion_tokens: 200 }; + stripUnsupportedParams("github", "gpt-5.4", body); + assert.equal(body.temperature, undefined); + assert.equal(body.max_completion_tokens, 200); +}); + +test("stripUnsupportedParams: github + gpt-5 (non-5.4) keeps temperature", () => { + const body: Record = { temperature: 1 }; + stripUnsupportedParams("github", "gpt-5", body); + assert.equal(body.temperature, 1); +}); + +test("stripUnsupportedParams: github + Claude strips thinking + reasoning_effort", () => { + const body: Record = { + thinking: { type: "enabled" }, + reasoning_effort: "high", + temperature: 0.5, + }; + stripUnsupportedParams("github", "claude-3-5-sonnet", body); + assert.equal(body.thinking, undefined, "Copilot rejects Claude-style thinking"); + assert.equal(body.reasoning_effort, undefined); + assert.equal(body.temperature, 0.5, "non-targeted params survive"); +}); + +test("stripUnsupportedParams: github + claude opus 4.6 KEEPS thinking + reasoning_effort", () => { + const body: Record = { + thinking: { type: "enabled" }, + reasoning_effort: "high", + }; + stripUnsupportedParams("github", "claude-opus-4.6", body); + assert.equal(body.reasoning_effort, "high", "opus-4.6 supports reasoning_effort"); + // Note: thinking survives too on opus-4.6 per upstream rule. + assert.deepEqual(body.thinking, { type: "enabled" }); +}); + +test("stripUnsupportedParams: github + claude sonnet 4.6 KEEPS reasoning_effort", () => { + const body: Record = { reasoning_effort: "low" }; + stripUnsupportedParams("github", "claude-sonnet-4.6", body); + assert.equal(body.reasoning_effort, "low"); +}); + +test("stripUnsupportedParams: non-github provider + Claude does NOT strip thinking", () => { + // The github-Claude rule is provider-scoped. + const body: Record = { thinking: { type: "enabled" } }; + stripUnsupportedParams("anthropic", "claude-3-5-sonnet", body); + assert.deepEqual(body.thinking, { type: "enabled" }); +}); + +test("stripUnsupportedParams: only deletes when the key is present (never adds undefined)", () => { + const body: Record = { max_tokens: 50 }; + stripUnsupportedParams("anthropic", "claude-opus-4", body); + // No `temperature` key should be introduced. + assert.equal("temperature" in body, false); +}); + +test("stripUnsupportedParams: returns the same body reference (in-place mutation)", () => { + const body: Record = { temperature: 0.7 }; + const out = stripUnsupportedParams("anthropic", "claude-opus-4", body); + assert.equal(out, body); +}); + +test("stripUnsupportedParams: null/undefined body is a no-op", () => { + assert.equal(stripUnsupportedParams("anthropic", "claude-opus-4", null), null); + assert.equal(stripUnsupportedParams("anthropic", "claude-opus-4", undefined), undefined); +}); + +test("stripUnsupportedParams: missing model is a no-op", () => { + const body: Record = { temperature: 0.7 }; + stripUnsupportedParams("anthropic", "", body); + assert.equal(body.temperature, 0.7); +}); + +test("STRIP_RULES is non-empty and every rule has a drop list", () => { + assert.ok(__STRIP_RULES_FOR_TEST.length > 0); + for (const rule of __STRIP_RULES_FOR_TEST) { + assert.ok(Array.isArray(rule.drop) && rule.drop.length > 0); + assert.ok(typeof rule.match === "function" || rule.match instanceof RegExp); + } +}); From be2ab9e419c1cee9590c98859b208a8f9d45e012 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:56:21 -0300 Subject: [PATCH 031/120] fix(claude-oauth): respect 429 backoff on usage endpoint to reduce spam (#4655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — port rebuilt clean over release tip, release-green validated --- open-sse/services/claudeUsageCooldown.ts | 51 ++++++++++++++ open-sse/services/usage.ts | 15 +++++ .../claude-oauth-usage-429-backoff.test.ts | 66 +++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 open-sse/services/claudeUsageCooldown.ts create mode 100644 tests/unit/claude-oauth-usage-429-backoff.test.ts diff --git a/open-sse/services/claudeUsageCooldown.ts b/open-sse/services/claudeUsageCooldown.ts new file mode 100644 index 000000000000..01de93dec9f2 --- /dev/null +++ b/open-sse/services/claudeUsageCooldown.ts @@ -0,0 +1,51 @@ +/** + * Per-token cooldown for the Claude OAuth usage endpoint + * (`https://api.anthropic.com/api/oauth/usage`). + * + * Anthropic rate-limits this quota endpoint independently of `/v1/messages`. + * When polled from multiple connections at once (dashboard auto-refresh + the + * combo health-scheduler), it spams `429` and that surfaces as noisy provider + * errors plus increased upstream load. Chat with the same token still works. + * + * We track a per-access-token "skip until" timestamp. When we see a `429`, + * we suppress further OAuth-usage polls for that token until the cooldown + * expires and the caller falls back to the legacy settings/org endpoint + * (which is what `getClaudeUsage` does on every non-OK response). + * + * Pure helpers (no fetch, no timers) keep this TDD-friendly. + * + * Inspired-by upstream 9router commit `79df34ca`. + */ + +export const OAUTH_USAGE_429_COOLDOWN_MS = 180_000; // 3 minutes + +const oauthCooldown = new Map(); + +/** Returns true while `accessToken` is still inside its 429 cooldown window. */ +export function isClaudeOauthUsageCoolingDown( + accessToken: string | undefined, + now: number = Date.now() +): boolean { + if (!accessToken) return false; + const until = oauthCooldown.get(accessToken); + if (until === undefined) return false; + if (until > now) return true; + // Lazy GC of expired entries — keeps the Map bounded by active tokens. + oauthCooldown.delete(accessToken); + return false; +} + +/** Record a 429 from the OAuth usage endpoint for `accessToken`. */ +export function markClaudeOauthUsage429( + accessToken: string | undefined, + now: number = Date.now(), + cooldownMs: number = OAUTH_USAGE_429_COOLDOWN_MS +): void { + if (!accessToken) return; + oauthCooldown.set(accessToken, now + cooldownMs); +} + +/** Test-only: clear all entries. */ +export function _resetClaudeOauthUsageCooldown(): void { + oauthCooldown.clear(); +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index edd14b8a09dd..5be8ab269291 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -37,6 +37,10 @@ import { } from "../executors/antigravity.ts"; import { getCreditsMode } from "./antigravityCredits.ts"; import { CLAUDE_CODE_VERSION, fetchClaudeBootstrap } from "../executors/claudeIdentity.ts"; +import { + isClaudeOauthUsageCoolingDown, + markClaudeOauthUsage429, +} from "./claudeUsageCooldown.ts"; import { generateAntigravityRequestId, getAntigravitySessionId } from "./antigravityIdentity.ts"; import { extractCodeAssistOnboardTierId, @@ -2564,6 +2568,12 @@ async function getClaudeUsage(accessToken?: string) { // Refresh bootstrap in parallel; best-effort, failure non-fatal. const bootstrapPromise = fetchClaudeBootstrap(accessToken).catch(() => null); + // Skip OAuth usage call while this token is cooling down from a recent 429 + // (chat with the same token still works — only the quota endpoint is throttled). + if (isClaudeOauthUsageCoolingDown(accessToken)) { + const legacy = await getClaudeUsageLegacy(accessToken); + return { ...legacy, bootstrap: await bootstrapPromise }; + } try { // Real CLI uses axios here, not Stainless — UA is `claude-code/` // (not `claude-cli/...`) and the shape is simpler than /v1/messages. @@ -2649,6 +2659,11 @@ async function getClaudeUsage(accessToken?: string) { }; } + // Cool down OAuth usage polling after a 429 (quota endpoint only — chat is unaffected). + if (oauthResponse.status === 429) { + markClaudeOauthUsage429(accessToken); + } + // Fallback: OAuth endpoint returned non-OK, try legacy settings/org endpoint console.warn( `[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy` diff --git a/tests/unit/claude-oauth-usage-429-backoff.test.ts b/tests/unit/claude-oauth-usage-429-backoff.test.ts new file mode 100644 index 000000000000..3406421a8bf7 --- /dev/null +++ b/tests/unit/claude-oauth-usage-429-backoff.test.ts @@ -0,0 +1,66 @@ +import { describe, it, beforeEach } from "node:test"; +import { strict as assert } from "node:assert"; + +import { + OAUTH_USAGE_429_COOLDOWN_MS, + _resetClaudeOauthUsageCooldown, + isClaudeOauthUsageCoolingDown, + markClaudeOauthUsage429, +} from "../../open-sse/services/claudeUsageCooldown.ts"; + +describe("claude OAuth usage 429 backoff", () => { + beforeEach(() => { + _resetClaudeOauthUsageCooldown(); + }); + + it("starts uncooled for any token", () => { + assert.equal(isClaudeOauthUsageCoolingDown("tok-a"), false); + assert.equal(isClaudeOauthUsageCoolingDown(undefined), false); + }); + + it("treats a single 429 as entering cooldown for that token only", () => { + const t0 = 1_000_000; + markClaudeOauthUsage429("tok-a", t0); + assert.equal(isClaudeOauthUsageCoolingDown("tok-a", t0 + 1), true); + // A different token is unaffected — cooldown is per-token, not global. + assert.equal(isClaudeOauthUsageCoolingDown("tok-b", t0 + 1), false); + }); + + it("repeated 429s on the same token do NOT spam: stays cooling once entered", () => { + const t0 = 2_000_000; + markClaudeOauthUsage429("tok-a", t0); + // Simulate the scheduler hitting 429 again 10s, 30s, 60s later + for (const dt of [10_000, 30_000, 60_000]) { + markClaudeOauthUsage429("tok-a", t0 + dt); + assert.equal( + isClaudeOauthUsageCoolingDown("tok-a", t0 + dt), + true, + `still cooling at +${dt}ms` + ); + } + }); + + it("cooldown expires after OAUTH_USAGE_429_COOLDOWN_MS and the token becomes eligible again", () => { + const t0 = 3_000_000; + markClaudeOauthUsage429("tok-a", t0); + assert.equal( + isClaudeOauthUsageCoolingDown("tok-a", t0 + OAUTH_USAGE_429_COOLDOWN_MS - 1), + true + ); + assert.equal( + isClaudeOauthUsageCoolingDown("tok-a", t0 + OAUTH_USAGE_429_COOLDOWN_MS + 1), + false, + "should be eligible again after cooldown window" + ); + }); + + it("undefined/missing access token is a no-op (does not poison the map)", () => { + const t0 = 4_000_000; + markClaudeOauthUsage429(undefined, t0); + assert.equal(isClaudeOauthUsageCoolingDown(undefined, t0 + 1), false); + }); + + it("cooldown defaults to 3 minutes (matches upstream OAUTH_429_COOLDOWN_MS)", () => { + assert.equal(OAUTH_USAGE_429_COOLDOWN_MS, 180_000); + }); +}); From 69703ee7dbfc4048b5d3c711976f4131c4ef9b08 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:56:57 -0300 Subject: [PATCH 032/120] feat(api/v1): include alias-backed models in /v1/models listing (#4630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — port rebuilt clean over release tip, release-green validated --- config/quality/file-size-baseline.json | 3 +- src/app/api/v1/models/aliasBackedModels.ts | 50 +++++++++++ src/app/api/v1/models/catalog.ts | 84 +++++++++++++++++++ tests/unit/v1-models-aliases-included.test.ts | 79 +++++++++++++++++ 4 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 src/app/api/v1/models/aliasBackedModels.ts create mode 100644 tests/unit/v1-models-aliases-included.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index f0e4848b59d7..55b646b871dc 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -8,6 +8,7 @@ "_rebaseline_2026_06_21_4481_websearch_routing": "Feature #4481 layer 2 own growth: src/sse/handlers/chat.ts 1491->1513 (+22 = the CCR-style web-search routing hook at the request entrypoint, right after the T05 task-aware-routing block — a 5-line comment + one `if (hasNativeWebSearchTool(body)) { ... }` guard that reads getCachedSettings only when a web_search tool is present and overrides resolvedModelStr/body.model via the pure helper, plus a 4-line import). When a request carries a native web_search server tool and the operator set `webSearchRouteModel`, the whole request routes to that model instead of the default (some providers, e.g. MiniMax, don't implement Anthropic's web_search_20250305 server tool). The detection + override logic lives in the new pure leaf open-sse/services/webSearchRouting.ts (no DB, unit-testable); chat.ts is thin wiring mirroring the adjacent T05 override. Lands BEFORE auto/combo resolution + the layer-1 webSearchFallback so the target's own format/fallback handling applies. Not extractable further (it IS the entrypoint wiring). Covered by tests/unit/web-search-tool-routing-4481.test.ts. Structural shrink of this handler tracked separately.", "_rebaseline_2026_06_21_4483_auto_quota_cutoff": "PR #4483 (megamen32) own growth + review fix: open-sse/services/combo.ts 2611->2623 (+12). The PR adds an auto-routing hard quota cutoff in buildAutoCandidates (evaluateQuotaCutoff + buildAutoQuotaThresholds/clampPercent/asThresholdMap/quotaWindowLookupNames helpers) that drops low-quota candidates before scoring, plus a 429 guard when all candidates are below cutoff. On review (owner decision) the cutoff was made OPT-IN behind a new QuotaPreflightSettings.enabled flag (default OFF via QUOTA_PREFLIGHT_CUTOFF_ENABLED) so default behavior is unchanged, and the `...eligibleTargets` last-resort fallback the PR removed was restored (dedupe makes it identical to pre-cutoff when OFF; when ON a blocked target survives as final fallback rather than vanishing). Cohesive at the existing candidate-build/select chokepoints; not extractable. Covered by tests/unit/combo/auto-quota-cutoff.test.ts + tests/unit/resilience-settings-quota-preflight.test.ts (default-off + opt-in round-trip).", "_rebaseline_2026_06_21_4475_target_format_badge": "PR #4475 (adivekar-utexas) review: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts 955->974 (+19). Extracted the pure targetFormatBadgeI18nKey (the 6 targetFormat value->i18n-key mapping) out of the CustomModelsSection.tsx badge so it is unit-testable outside the .tsx (Rule #18 gap — the PR had no UI test). The .tsx now calls the helper instead of an inline if-chain. This leaf is the strangler-fig home for pure provider-page helpers (#3501), so receiving the extraction is on-purpose. Covered by tests/unit/provider-target-format-badge-4475.test.ts.", + "_rebaseline_2026_06_22_port_730_alias_backed_models": "Port of decolua/9router#730 own growth: src/app/api/v1/models/catalog.ts 1493->1574 (+81 = one import + one alias-backed walk after the customModels block, mirroring the existing static/custom emission path: resolve canonical providerId, gate on blocked/active/hidden, push aliasId + optional canonical providerPrefixedId with the same vision-fields enrichment). Surfaces models registered ONLY through the modelAliases key_value namespace (value = \"/\", e.g. setModelAlias(\"kimi-k2.6\", \"custom/kimi-k2.6\")) so compatible-provider entries that resolve at request time but were silently dropped from /v1/models are now discoverable by OpenAI clients. Pair-extract is in the new pure leaf src/app/api/v1/models/aliasBackedModels.ts (no DB import, unit-testable via tests/unit/v1-models-aliases-included.test.ts). Cohesive wiring next to the other per-source push blocks; not extractable further without hiding the per-source dedupe boundary. Structural shrink of this god-file tracked in #3789.", "_rebaseline_2026_06_21_4424_exact_dup_dedupe": "Feature #4424 follow-up own growth: src/app/api/v1/models/catalog.ts 1486->1493 (+7 = the final exact-duplicate-id guard at the existing finalModels chokepoint — one import + a 4-line comment + one `finalModels = dedupeExactCatalogIds(finalModels)` call). #4427 added the opt-in prefix mode but the catalog still emitted 3 exact-duplicate ids (codex/gpt-5.5, veo-free/seedance, veo-free/veo each listed twice) because they originate from different push sources whose local guards don't see each other. The collapse logic lives in the new pure leaf src/app/api/v1/models/catalogDedupe.ts (no DB import, unit-testable). Keyed by listing identity (id, type, subtype) so the intentional same-id audio transcription/speech pair is preserved; keep-first, order-preserving, independent of MODELS_CATALOG_PREFIX_MODE. Thin cohesive wiring at the single serialization boundary; not extractable further. Covered by tests/unit/models-catalog-exact-dup-4424.test.ts. Structural shrink of this god-file tracked in #3789.", "_rebaseline_2026_06_21_4427_low_noise_catalog": "PR #4427 (Rahulsharma0810) own growth: src/app/api/v1/models/catalog.ts 1478->1486 (+8). The opt-in MODELS_CATALOG_PREFIX_MODE (dual default | alias | canonical, with ?prefix= per-request override) gates the dual alias+canonical model emission at the three /v1/models push sites (static, synced, custom) behind includeAlias/includeCanonical, suppressing the duplicate cross-prefix entries (net +3 from the feature). On review, 4 incidental explanatory comments removed by the PR were restored (synced-models resolve, skip-static, try-block intent, strip-modelIdPrefix; +5) since their code is unchanged — useful docs on a non-trivial catalog function. Default `dual` keeps byte-identical output; request-side alias resolution unchanged. Structural shrink of this route tracked in #3789. Covered by tests/unit/models-catalog-low-noise-flag.test.ts.", "_rebaseline_2026_06_21_phase2_active_selector": "Compression Phase 2 (named profiles + active selector) own growth: chatCore.ts 5110->5125 (+15 at the existing compression-dispatch chokepoint, just before the selectCompressionStrategy call). chatCore now loads the operator's named compression combos once into a `namedCombos` map (a best-effort try/catch dynamic-import of listCompressionCombos, debug-logged on failure) and passes it as the new `combos` arg to selectCompressionStrategy + selectCompressionPlan, plus an `&& !activeComboResolves(config, namedCombos)` guard term on the legacy default-combo block so the seeded default cannot shadow the operator's active profile. The resolver itself stays pure (the `combos` threading + activeComboResolves live in open-sse/services/compression/strategySelector.ts, 277 (master toggle/mode/reorder removed, replaced by the thin active-profile selector + preview) and CompressionCombosPageClient.tsx grows a few lines under its frozen for the active badge. Cohesive wiring at the existing compression chokepoint, mirroring the prior compression rebaselines (#4217/#4210/#3890); not extractable without hiding the dispatch boundary. Structural shrink of chatCore.ts tracked in #3501. Covered by tests/unit/compression/active-combo-dispatch.test.ts + active-combo-integration.test.ts + tests/unit/ui/compressionHub-active-selector.test.tsx + namedCombos-active-badge.test.tsx.", @@ -188,7 +189,7 @@ "src/app/api/providers/[id]/models/route.ts": 2590, "src/app/api/providers/[id]/test/route.ts": 917, "src/app/api/usage/analytics/route.ts": 941, - "src/app/api/v1/models/catalog.ts": 1493, + "src/app/api/v1/models/catalog.ts": 1574, "src/lib/cloudflaredTunnel.ts": 934, "src/lib/db/apiKeys.ts": 1662, "src/lib/db/core.ts": 1825, diff --git a/src/app/api/v1/models/aliasBackedModels.ts b/src/app/api/v1/models/aliasBackedModels.ts new file mode 100644 index 000000000000..4d599107daee --- /dev/null +++ b/src/app/api/v1/models/aliasBackedModels.ts @@ -0,0 +1,50 @@ +/** + * Extract `{providerKey, modelId}` pairs from the `modelAliases` key_value namespace + * so `/v1/models` can surface models that were registered ONLY through a model alias + * (e.g. `setModelAlias("kimi-k2.6", "custom/kimi-k2.6")`). + * + * Port of upstream decolua/9router PR #730 — kept as a PURE helper so the route can + * walk the result and merge it into the per-provider catalog without dragging DB or + * Next-runtime imports into a unit test. + * + * Each stored alias value is `"/"`. `providerKey` may be either + * a provider id (e.g. `openai`) or a provider alias / prefix (e.g. `cu`, `custom`); + * the caller is responsible for resolving that against the active connection map. + * + * The split is on the FIRST `/` only — OpenRouter-style ids contain an internal slash + * (`openrouter/anthropic/claude-3.5-sonnet`) and must keep their full sub-path as the + * model id. + */ +export interface AliasBackedModel { + providerKey: string; + modelId: string; +} + +export function extractAliasBackedModels( + aliases: Record | null | undefined +): AliasBackedModel[] { + if (!aliases || typeof aliases !== "object") return []; + + const seen = new Set(); + const out: AliasBackedModel[] = []; + + for (const value of Object.values(aliases)) { + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (trimmed.length === 0) continue; + + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash === trimmed.length - 1) continue; + + const providerKey = trimmed.slice(0, slash); + const modelId = trimmed.slice(slash + 1); + if (!providerKey || !modelId) continue; + + const dedupeKey = `${providerKey}/${modelId}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + out.push({ providerKey, modelId }); + } + + return out; +} diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index aabacf77fa75..2cc2c90c9c58 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -8,7 +8,9 @@ import { getSettings, getProviderNodes, getModelIsHidden, + getModelAliases, } from "@/lib/localDb"; +import { extractAliasBackedModels } from "./aliasBackedModels"; import { appendNoThinkingVariants } from "@omniroute/open-sse/utils/noThinkingAlias"; import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry"; import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry"; @@ -1323,6 +1325,88 @@ export async function getUnifiedModelsResponse( console.log("Could not fetch custom models"); } + // Port of decolua/9router#730 — surface models registered ONLY through a model + // alias (`key_value` namespace `modelAliases`, value `"/"`). + // Without this walk, a compatible-provider entry like `setModelAlias("kimi-k2.6", + // "custom/kimi-k2.6")` resolves at request time but never shows up in `/v1/models`. + // We respect the same gating as the static/custom listing path: provider must be + // active (or noAuth+unblocked), model must not be hidden, and the canonical alias + // entry must not already exist (so we don't shadow combo / synced / custom rows). + try { + const modelAliases = await getModelAliases(); + const aliasBacked = extractAliasBackedModels(modelAliases); + for (const { providerKey, modelId } of aliasBacked) { + const canonicalProviderId = resolveCanonicalProviderId(providerKey); + if (!canonicalProviderId) continue; + if ( + blockedProviders.has(providerKey) || + blockedProviders.has(canonicalProviderId) || + isNoAuthProviderBlocked(blockedProviders, canonicalProviderId, providerKey) + ) { + continue; + } + + const alias = providerIdToAlias[canonicalProviderId] || providerKey; + if ( + !activeAliases.has(alias) && + !activeAliases.has(canonicalProviderId) && + !activeAliases.has(providerKey) + ) { + continue; + } + + if (getModelIsHidden(canonicalProviderId, modelId)) continue; + + const aliasId = `${alias}/${modelId}`; + const rawPrefixedId = `${providerKey}/${modelId}`; + if ( + models.some((m: any) => m?.id === aliasId) || + models.some((m: any) => m?.id === rawPrefixedId) + ) { + continue; + } + + const visionFields = + getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(modelId); + + if (includeAlias) { + models.push({ + id: aliasId, + object: "model", + created: timestamp, + owned_by: canonicalProviderId, + permission: [], + root: modelId, + parent: null, + ...(visionFields || {}), + }); + } + if ( + includeCanonical && + canonicalProviderId !== alias && + !isNoAuthProviderKey(canonicalProviderId) && + prefixRoutesToProvider(canonicalProviderId, canonicalProviderId) + ) { + const providerPrefixedId = `${canonicalProviderId}/${modelId}`; + if (models.some((m: any) => m?.id === providerPrefixedId)) continue; + const providerVisionFields = + getVisionCapabilityFields(providerPrefixedId) || getVisionCapabilityFields(modelId); + models.push({ + id: providerPrefixedId, + object: "model", + created: timestamp, + owned_by: canonicalProviderId, + permission: [], + root: modelId, + parent: includeAlias ? aliasId : null, + ...(providerVisionFields || {}), + }); + } + } + } catch (e) { + console.log("Could not fetch model aliases"); + } + // Add managed fallback models for compatible providers that don't import a model list. for (const conn of connections) { const providerId = typeof conn.provider === "string" ? conn.provider : null; diff --git a/tests/unit/v1-models-aliases-included.test.ts b/tests/unit/v1-models-aliases-included.test.ts new file mode 100644 index 000000000000..eb59e45597d2 --- /dev/null +++ b/tests/unit/v1-models-aliases-included.test.ts @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Port of upstream decolua/9router PR #730 — `/v1/models` must surface models added +// through alias mappings (key_value namespace `modelAliases`) so compatible-provider +// entries like `custom/kimi-k2.6` registered only via `setModelAlias("kimi-k2.6", +// "custom/kimi-k2.6")` are still discoverable by OpenAI clients. +// +// The catalog already iterates `PROVIDER_MODELS`, synced available models, and +// `customModels`. It never iterated `modelAliases`, so values pointing to bare +// `/` entries were silently dropped from the listing even +// though the model resolves at request time. This helper exposes the alias-backed +// pairs as a pure function so the route can merge them into the per-provider model +// list without changing the rest of the pipeline. + +import { extractAliasBackedModels } from "../../src/app/api/v1/models/aliasBackedModels.ts"; + +test("returns the {providerKey, modelId} pair for each alias-backed entry", () => { + const aliases = { + "kimi-k2.6": "custom/kimi-k2.6", + "gpt-4o-mini-fast": "openai/gpt-4o-mini", + }; + const out = extractAliasBackedModels(aliases); + assert.deepEqual(out.sort((a, b) => a.providerKey.localeCompare(b.providerKey)), [ + { providerKey: "custom", modelId: "kimi-k2.6" }, + { providerKey: "openai", modelId: "gpt-4o-mini" }, + ]); +}); + +test("ignores alias values that are not strings", () => { + const aliases = { + bad1: 42, + bad2: null, + bad3: { foo: "openai/gpt-4o" }, + good: "anthropic/claude-3-5-sonnet", + }; + const out = extractAliasBackedModels(aliases); + assert.deepEqual(out, [{ providerKey: "anthropic", modelId: "claude-3-5-sonnet" }]); +}); + +test("ignores alias values without a provider/model split", () => { + const aliases = { + no_slash: "just-a-model-id", + empty: "", + only_prefix: "openai/", + only_suffix: "/model", + }; + const out = extractAliasBackedModels(aliases); + assert.deepEqual(out, []); +}); + +test("preserves the full sub-path when the model id itself contains a slash", () => { + // e.g. openrouter-style "owner/model" identifiers + const aliases = { + or_alias: "openrouter/anthropic/claude-3.5-sonnet", + }; + const out = extractAliasBackedModels(aliases); + assert.deepEqual(out, [ + { providerKey: "openrouter", modelId: "anthropic/claude-3.5-sonnet" }, + ]); +}); + +test("returns an empty list for an empty / null / undefined input", () => { + assert.deepEqual(extractAliasBackedModels({}), []); + assert.deepEqual(extractAliasBackedModels(null as unknown as Record), []); + assert.deepEqual( + extractAliasBackedModels(undefined as unknown as Record), + [] + ); +}); + +test("de-duplicates entries that resolve to the same {providerKey, modelId}", () => { + const aliases = { + primary: "custom/kimi-k2.6", + secondary: "custom/kimi-k2.6", + }; + const out = extractAliasBackedModels(aliases); + assert.deepEqual(out, [{ providerKey: "custom", modelId: "kimi-k2.6" }]); +}); From 306863dd9e991fe11d0f7e652f880c874c9c98d3 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:58:44 -0300 Subject: [PATCH 033/120] chore(quality): rebaseline catalog.ts 1574->1577 (#4630 aliases sobre quota-exclusive da release) (#4879) rebaseline --- config/quality/file-size-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 55b646b871dc..a4a08822d995 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -189,7 +189,7 @@ "src/app/api/providers/[id]/models/route.ts": 2590, "src/app/api/providers/[id]/test/route.ts": 917, "src/app/api/usage/analytics/route.ts": 941, - "src/app/api/v1/models/catalog.ts": 1574, + "src/app/api/v1/models/catalog.ts": 1577, "src/lib/cloudflaredTunnel.ts": 934, "src/lib/db/apiKeys.ts": 1662, "src/lib/db/core.ts": 1825, From ec1c5df0910994e9d17f11648a80c8a0c348d625 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:58:48 -0300 Subject: [PATCH 034/120] feat(compression): Kiro/CodeWhisperer tool-result compression engine (#4635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — port rebuilt clean, release-green --- open-sse/services/compression/bodyAdapter.ts | 184 ++++++++++++++ .../unit/kiro-toolresult-compression.test.ts | 238 ++++++++++++++++++ 2 files changed, 422 insertions(+) create mode 100644 tests/unit/kiro-toolresult-compression.test.ts diff --git a/open-sse/services/compression/bodyAdapter.ts b/open-sse/services/compression/bodyAdapter.ts index 2a65c21a9483..a0504831f488 100644 --- a/open-sse/services/compression/bodyAdapter.ts +++ b/open-sse/services/compression/bodyAdapter.ts @@ -2,6 +2,7 @@ type MessageLike = { role?: unknown; content?: unknown; [COMPRESSION_INPUT_INDEX]?: number; + [KIRO_TOOL_RESULT_PATH]?: KiroToolResultPath; [key: string]: unknown; }; @@ -16,6 +17,23 @@ type ResponsesItem = { const RESPONSES_MESSAGE_TYPES = new Set(["message", "function_call_output"]); const COMPRESSION_INPUT_INDEX = Symbol("compressionInputIndex"); +// Kiro envelope path back to the original tool-result text inside +// `conversationState.{history|currentMessage}[].userInputMessage.userInputMessageContext.toolResults[N].content[M].text`. +// Stored on the synthesized role:"tool" message so we can restore the rewritten text +// to the exact source slot after any compression engine runs. +// +// This lifts Kiro support from RTK-only (upstream decolua/9router#1194) to the shared +// adapter layer, so every compression engine (RTK, lite, aggressive…) automatically +// covers Kiro/CodeWhisperer payloads, not just RTK. +const KIRO_TOOL_RESULT_PATH = Symbol("kiroToolResultPath"); + +type KiroToolResultPath = { + scope: "currentMessage" | "history"; + historyIndex: number; // ignored when scope === "currentMessage" + toolResultIndex: number; + contentIndex: number; +}; + function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -97,6 +115,14 @@ export function adaptBodyForCompression(body: Record): Compress }; } + // Kiro / AWS CodeWhisperer envelope: tool results live deep inside + // conversationState.{currentMessage|history[]}.userInputMessage.userInputMessageContext.toolResults. + // Flatten the tool-result text into synthesized role:"tool" messages so every engine + // can compress them, then restore the rewritten text back into the exact source slot. + if (isRecord(body.conversationState)) { + return adaptKiroBodyForCompression(body); + } + if (!Array.isArray(body.input) && typeof body.input !== "string") { return { body, @@ -158,3 +184,161 @@ export function adaptBodyForCompression(body: Record): Compress }, }; } + +/** + * Flatten a Kiro / AWS CodeWhisperer body so compression engines can rewrite the + * tool-result text, then restore the rewritten text back into the original envelope + * shape. Mirrors the OpenAI tool-role contract: + * + * - Each surviving tool-result content block becomes one role:"tool" message whose + * `content` is its inner text (engines see a familiar shape). + * - Error tool results (`status === "error"`) are intentionally left out of the + * synthesized messages so engines cannot rewrite them — preserves diagnostics + * byte-for-byte (matches upstream decolua/9router#1194 behavior). + * - Non-string text parts are skipped. + * - On restore, only string `content` (or an array whose first text part is a string) + * is written back to `toolResults[N].content[M].text`. + */ +function adaptKiroBodyForCompression(body: Record): CompressionBodyAdapter { + const state = body.conversationState as Record; + const history = Array.isArray(state.history) ? (state.history as Array) : []; + const currentMessage = isRecord(state.currentMessage) ? state.currentMessage : null; + + const messages: MessageLike[] = []; + + const collectFrom = ( + container: Record, + scope: "currentMessage" | "history", + historyIndex: number + ): void => { + const uim = container.userInputMessage; + if (!isRecord(uim)) return; + const ctx = uim.userInputMessageContext; + if (!isRecord(ctx)) return; + const toolResults = ctx.toolResults; + if (!Array.isArray(toolResults)) return; + toolResults.forEach((tr, trIdx) => { + if (!isRecord(tr)) return; + if (tr.status === "error") return; // preserve error traces — never compress + const content = tr.content; + if (!Array.isArray(content)) return; + content.forEach((part, partIdx) => { + if (!isRecord(part)) return; + if (typeof part.text !== "string" || part.text.length === 0) return; + messages.push({ + role: "tool", + content: part.text, + [KIRO_TOOL_RESULT_PATH]: { + scope, + historyIndex, + toolResultIndex: trIdx, + contentIndex: partIdx, + }, + }); + }); + }); + }; + + history.forEach((entry, idx) => { + if (!isRecord(entry)) return; + collectFrom(entry, "history", idx); + }); + if (currentMessage) collectFrom(currentMessage, "currentMessage", -1); + + if (messages.length === 0) { + return { + body, + adapted: false, + restore: (compressedBody) => compressedBody, + }; + } + + return { + body: { ...body, messages }, + adapted: true, + restore(compressedBody) { + // Build a path → rewritten text map from the synthesized tool messages. + const rewrites = new Map(); + if (Array.isArray(compressedBody.messages)) { + for (const message of compressedBody.messages as MessageLike[]) { + const path = message[KIRO_TOOL_RESULT_PATH]; + if (!path) continue; + let nextText: string | null = null; + if (typeof message.content === "string") { + nextText = message.content; + } else if (Array.isArray(message.content)) { + const firstText = message.content.find( + (part): part is { text: string } => + isRecord(part) && typeof (part as { text?: unknown }).text === "string" + ); + if (firstText) nextText = firstText.text; + } + if (nextText === null) continue; + rewrites.set(kiroPathKey(path), nextText); + } + } + + const nextState: Record = { ...state }; + // Rebuild history with any rewritten tool-result text. + if (history.length > 0) { + nextState.history = history.map((entry, idx) => { + if (!isRecord(entry)) return entry; + return rewriteKiroEntry(entry, "history", idx, rewrites); + }); + } + if (currentMessage) { + nextState.currentMessage = rewriteKiroEntry(currentMessage, "currentMessage", -1, rewrites); + } + + const rest = { ...compressedBody }; + delete rest.messages; + return { ...rest, conversationState: nextState }; + }, + }; +} + +function kiroPathKey(path: KiroToolResultPath): string { + return `${path.scope}|${path.historyIndex}|${path.toolResultIndex}|${path.contentIndex}`; +} + +function rewriteKiroEntry( + entry: Record, + scope: "currentMessage" | "history", + historyIndex: number, + rewrites: Map +): Record { + const uim = entry.userInputMessage; + if (!isRecord(uim)) return entry; + const ctx = uim.userInputMessageContext; + if (!isRecord(ctx)) return entry; + const toolResults = ctx.toolResults; + if (!Array.isArray(toolResults)) return entry; + + let entryChanged = false; + const nextToolResults = toolResults.map((tr, trIdx) => { + if (!isRecord(tr)) return tr; + const content = tr.content; + if (!Array.isArray(content)) return tr; + let trChanged = false; + const nextContent = content.map((part, partIdx) => { + if (!isRecord(part) || typeof part.text !== "string") return part; + const key = kiroPathKey({ scope, historyIndex, toolResultIndex: trIdx, contentIndex: partIdx }); + const rewritten = rewrites.get(key); + if (rewritten === undefined || rewritten === part.text) return part; + trChanged = true; + return { ...part, text: rewritten }; + }); + if (!trChanged) return tr; + entryChanged = true; + return { ...tr, content: nextContent }; + }); + + if (!entryChanged) return entry; + return { + ...entry, + userInputMessage: { + ...uim, + userInputMessageContext: { ...ctx, toolResults: nextToolResults }, + }, + }; +} diff --git a/tests/unit/kiro-toolresult-compression.test.ts b/tests/unit/kiro-toolresult-compression.test.ts new file mode 100644 index 000000000000..4db2bc5dbbeb --- /dev/null +++ b/tests/unit/kiro-toolresult-compression.test.ts @@ -0,0 +1,238 @@ +/** + * Kiro (AWS CodeWhisperer) tool-result compression — ported from upstream + * decolua/9router#1194 (zanuartri). + * + * The upstream PR added Kiro-format support directly inside RTK. OmniRoute + * lifts that capability to `bodyAdapter`, so the Kiro envelope + * (`conversationState.history[].userInputMessage.userInputMessageContext.toolResults`) + * flattens to OpenAI-shape `role:"tool"` messages — every compression engine + * (RTK, lite, aggressive, etc.) automatically benefits, not just RTK. + * + * Error tool results (status === "error") are skipped to preserve diagnostics + * (matches upstream behavior). + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { applyRtkCompression } from "../../open-sse/services/compression/engines/rtk/index.ts"; +import { adaptBodyForCompression } from "../../open-sse/services/compression/bodyAdapter.ts"; + +function buildKiroBodyWithBuildOutput(): Record { + const noisyNpmOutput = [ + "npm warn deprecated har-validator@5.1.5: this library is no longer supported", + "npm warn deprecated uuid@3.4.0: uuid@10 and below is no longer supported", + "npm warn deprecated request@2.88.2: request has been deprecated", + "npm warn deprecated inflight@1.0.6: This module is not supported", + "npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported", + "npm warn deprecated rimraf@2.7.1: Rimraf versions prior to v4 are no longer supported", + "", + "added 47 packages, and audited 48 packages in 13s", + "", + "3 packages are looking for funding", + " run `npm fund` for details", + "", + "4 vulnerabilities (2 moderate, 2 critical)", + "", + "Some issues need review, and may require choosing", + "a different dependency.", + "", + "Run `npm audit` for details.", + ].join("\n"); + + return { + conversationState: { + chatTriggerType: "MANUAL", + conversationId: "test-port-1194", + currentMessage: { + userInputMessage: { + content: "Install express", + modelId: "claude-sonnet-4.5", + userInputMessageContext: { + toolResults: [ + { + toolUseId: "tool_1", + status: "success", + content: [{ text: noisyNpmOutput }], + }, + ], + }, + }, + }, + history: [], + }, + }; +} + +describe("Kiro tool-result compression (port of decolua/9router#1194)", () => { + it("adapts a Kiro conversationState body to messages with the tool-result text exposed", () => { + const body = buildKiroBodyWithBuildOutput(); + const adapter = adaptBodyForCompression(body); + const messages = (adapter.body.messages ?? []) as Array<{ + role?: string; + content?: unknown; + }>; + assert.ok(adapter.adapted, "Kiro body must be flagged as adapted"); + assert.ok( + messages.some((m) => m.role === "tool" && typeof m.content === "string"), + "adapter must surface tool-result text as a role:tool message" + ); + }); + + it("compresses tool-result text inside Kiro currentMessage and writes the rewritten text back", () => { + const body = buildKiroBodyWithBuildOutput(); + const before = ( + (body.conversationState as Record).currentMessage as Record + ).userInputMessage as { + userInputMessageContext: { toolResults: Array<{ content: Array<{ text: string }> }> }; + }; + const originalLen = before.userInputMessageContext.toolResults[0].content[0].text.length; + + const result = applyRtkCompression(body); + + assert.equal(result.compressed, true, "RTK must compress noisy build output"); + assert.ok(result.stats, "stats must be returned"); + assert.ok( + result.stats!.compressedTokens < result.stats!.originalTokens, + "compressedTokens must drop" + ); + + const restored = ( + (result.body as Record).conversationState as Record + ).currentMessage as { + userInputMessage: { + userInputMessageContext: { toolResults: Array<{ content: Array<{ text: string }> }> }; + }; + }; + const afterLen = restored.userInputMessage.userInputMessageContext.toolResults[0].content[0] + .text.length; + assert.ok( + afterLen < originalLen, + `Kiro tool-result text must shrink (before=${originalLen}, after=${afterLen})` + ); + }); + + it("compresses tool-result text inside Kiro history[] entries", () => { + // Reuse the noisy npm-install fixture (already proven to trigger a build-output + // filter in the currentMessage test) so this test exercises the history[] path + // without depending on a separate filter (cargo, etc.) being registered. + const compilingLines: string[] = [ + "npm warn deprecated har-validator@5.1.5: this library is no longer supported", + "npm warn deprecated uuid@3.4.0: uuid@10 and below is no longer supported", + "npm warn deprecated request@2.88.2: request has been deprecated", + "npm warn deprecated inflight@1.0.6: This module is not supported", + "npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported", + "npm warn deprecated rimraf@2.7.1: Rimraf versions prior to v4 are no longer supported", + "", + "added 47 packages, and audited 48 packages in 13s", + "", + "3 packages are looking for funding", + " run `npm fund` for details", + "", + "4 vulnerabilities (2 moderate, 2 critical)", + "", + "Run `npm audit` for details.", + ]; + + const body: Record = { + conversationState: { + chatTriggerType: "MANUAL", + conversationId: "test-history", + currentMessage: { + userInputMessage: { + content: "What happened?", + modelId: "claude-sonnet-4.5", + }, + }, + history: [ + { + userInputMessage: { + content: "Run npm install", + modelId: "claude-sonnet-4.5", + userInputMessageContext: { + toolResults: [ + { + toolUseId: "tool_2", + status: "success", + content: [{ text: compilingLines.join("\n") }], + }, + ], + }, + }, + }, + ], + }, + }; + + const originalLen = compilingLines.join("\n").length; + const result = applyRtkCompression(body); + + assert.equal(result.compressed, true, "history tool result must compress"); + const state = (result.body as Record).conversationState as { + history: Array<{ + userInputMessage: { + userInputMessageContext: { toolResults: Array<{ content: Array<{ text: string }> }> }; + }; + }>; + }; + const afterLen = state.history[0].userInputMessage.userInputMessageContext.toolResults[0] + .content[0].text.length; + assert.ok( + afterLen < originalLen, + `history tool-result text must shrink (before=${originalLen}, after=${afterLen})` + ); + }); + + it("preserves error tool results (status === 'error') without rewriting their text", () => { + const errorText = + "npm error code E404\nnpm error 404 Not Found - GET https://registry.npmjs.org/invalid-package"; + const body: Record = { + conversationState: { + chatTriggerType: "MANUAL", + conversationId: "test-error", + currentMessage: { + userInputMessage: { + content: "Install invalid-package", + modelId: "claude-sonnet-4.5", + userInputMessageContext: { + toolResults: [ + { + toolUseId: "tool_err", + status: "error", + content: [{ text: errorText }], + }, + ], + }, + }, + }, + history: [], + }, + }; + + const result = applyRtkCompression(body); + const after = ( + (result.body as Record).conversationState as { + currentMessage: { + userInputMessage: { + userInputMessageContext: { toolResults: Array<{ content: Array<{ text: string }> }> }; + }; + }; + } + ).currentMessage.userInputMessage.userInputMessageContext.toolResults[0].content[0].text; + assert.equal(after, errorText, "error tool-result text must be preserved byte-for-byte"); + }); + + it("handles a malformed Kiro body without crashing", () => { + const malformed: Array> = [ + { conversationState: null }, + { conversationState: {} }, + { conversationState: { history: null, currentMessage: null } }, + { conversationState: { history: "not-an-array" } }, + ]; + for (const body of malformed) { + const result = applyRtkCompression(body); + assert.ok(result, "must not throw on malformed Kiro body"); + // No tool-result text → engine reports no compression + assert.equal(result.compressed, false); + } + }); +}); From 486f01710cdf9a05f85e7e8552484d0b727fe414 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:58:52 -0300 Subject: [PATCH 035/120] fix(security): don't trust loopback socket as local when behind reverse proxy (#4632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — port rebuilt clean, release-green --- scripts/dev/peer-stamp.mjs | 31 ++++- src/server/authz/headers.ts | 16 +++ src/server/authz/peerStamp.ts | 65 ++++++++++ src/server/authz/pipeline.ts | 28 +++-- src/server/authz/policies/management.ts | 25 +++- .../route-guard-loopback-via-proxy.test.ts | 112 ++++++++++++++++++ 6 files changed, 260 insertions(+), 17 deletions(-) create mode 100644 tests/unit/route-guard-loopback-via-proxy.test.ts diff --git a/scripts/dev/peer-stamp.mjs b/scripts/dev/peer-stamp.mjs index b4e18d213af1..4951abcbdb3c 100644 --- a/scripts/dev/peer-stamp.mjs +++ b/scripts/dev/peer-stamp.mjs @@ -20,6 +20,20 @@ import { randomUUID } from "node:crypto"; */ export const PEER_IP_HEADER = "x-omniroute-peer-ip"; +/** + * Companion header to PEER_IP_HEADER: `|1` when the inbound TCP request + * carried forwarding headers (`x-forwarded-for` / `x-real-ip`), `|0` + * otherwise. Required so the middleware can tell that a loopback socket is the + * reverse-proxy hop (nginx / Caddy / Cloudflare Tunnel) and NOT trust it as + * local — without this, a leaked JWT over a public tunnel would reach the + * LOCAL_ONLY routes that spawn child processes (Hard Rules #15 + #17; + * port of upstream decolua/9router commit da667836). + * + * Keep VIA_PROXY_HEADER in sync with VIA_PROXY_HEADER in + * src/server/authz/headers.ts (the TS side cannot import this .mjs). + */ +export const VIA_PROXY_HEADER = "x-omniroute-via-proxy"; + /** Generate (once) and return the per-process stamp token, persisting it in env * so the middleware running in the same process reads the identical value. */ export function ensurePeerStampToken() { @@ -27,17 +41,26 @@ export function ensurePeerStampToken() { return process.env.OMNIROUTE_PEER_STAMP_TOKEN; } -/** Strip any client-supplied PEER_IP_HEADER and stamp the real TCP peer IP, - * token-prefixed. Never throws — a stamping failure must not block a request - * (it degrades to "locality unknown" → fail closed in the middleware). */ +/** Strip any client-supplied PEER_IP_HEADER + VIA_PROXY_HEADER and stamp the + * real TCP peer IP plus a token-protected via-proxy marker. Never throws — a + * stamping failure must not block a request (it degrades to "locality + * unknown" → fail closed in the middleware). */ export function stampPeerIp(req) { try { if (!req || !req.headers) return; // Node lowercases incoming header names; delete kills any client value. delete req.headers[PEER_IP_HEADER]; + delete req.headers[VIA_PROXY_HEADER]; const ip = req.socket && req.socket.remoteAddress; if (ip) { - req.headers[PEER_IP_HEADER] = `${ensurePeerStampToken()}|${ip}`; + const token = ensurePeerStampToken(); + req.headers[PEER_IP_HEADER] = `${token}|${ip}`; + // Forwarding headers present = request arrived via a reverse proxy; the + // loopback socket is the proxy hop, not the end-user, so it must not be + // trusted as local. Token-prefix the marker so a remote caller cannot + // forge it (or its absence) on a non-proxied request. + const viaProxy = !!(req.headers["x-forwarded-for"] || req.headers["x-real-ip"]); + req.headers[VIA_PROXY_HEADER] = `${token}|${viaProxy ? "1" : "0"}`; } } catch { /* never block a request on peer stamping */ diff --git a/src/server/authz/headers.ts b/src/server/authz/headers.ts index a7a3ec574e7b..002e6797395a 100644 --- a/src/server/authz/headers.ts +++ b/src/server/authz/headers.ts @@ -37,6 +37,22 @@ export const CLI_TOKEN_HEADER = "x-omniroute-cli-token"; */ export const PEER_IP_HEADER = "x-omniroute-peer-ip"; +/** + * Trusted "request arrived via a reverse proxy" marker stamped by the custom + * Node server alongside PEER_IP_HEADER, formatted as `|1` when the + * inbound TCP request carried forwarding headers (`x-forwarded-for` / + * `x-real-ip`) and `|0` otherwise. The middleware combines this with + * the stamped peer IP so a loopback / private-LAN socket that is actually the + * proxy hop (e.g. OmniRoute behind nginx / Caddy / Cloudflare Tunnel) is NOT + * trusted as local — closing the upstream da667836 vulnerability that would + * otherwise let a leaked JWT over a public tunnel reach LOCAL_ONLY routes + * that spawn child processes. Token-validated like PEER_IP_HEADER, so a + * remote caller cannot forge it. Stripped from forwarded headers before + * route handlers see it. + * Keep in sync with VIA_PROXY_HEADER in scripts/dev/peer-stamp.mjs. + */ +export const VIA_PROXY_HEADER = "x-omniroute-via-proxy"; + /** * Trusted locality verdict ("loopback" | "lan" | "remote") that the pipeline * computes from the stamped real peer IP and forwards to route handlers. Route diff --git a/src/server/authz/peerStamp.ts b/src/server/authz/peerStamp.ts index df099aa94ca1..ba80df018969 100644 --- a/src/server/authz/peerStamp.ts +++ b/src/server/authz/peerStamp.ts @@ -1,4 +1,5 @@ import { timingSafeEqual } from "node:crypto"; +import { classifyHostLocality } from "./routeGuard"; /** * Resolve the real peer IP from the trusted `|` stamp that the custom @@ -32,3 +33,67 @@ export function resolveStampedPeer( } return ip; } + +/** + * Resolve the trusted "request arrived via a reverse proxy" marker stamped by + * the custom Node server (`scripts/dev/peer-stamp.mjs::stampPeerIp`). The stamp + * is `|1` when forwarding headers (`x-forwarded-for` / `x-real-ip`) were + * present on the inbound TCP request, and `|0` otherwise. + * + * Returns true ONLY when the token constant-time-matches this process's stamp + * token AND the payload is exactly "1". Any other value — no stamp, forged + * token, "0", junk — returns false (the safe default: assume no proxy hop). + * + * SECURITY: paired with `resolveStampedPeer()` to close the upstream + * decolua/9router da667836 vulnerability — when OmniRoute itself runs behind + * an external reverse proxy (nginx / Caddy / Cloudflare Tunnel), + * `req.socket.remoteAddress` is the proxy hop (usually 127.0.0.1), not the + * end-user. Without this signal, `classifyHostLocality()` would return + * "loopback" for every remote caller arriving via the proxy, granting access + * to the LOCAL_ONLY tier that gates spawn-capable routes (Hard Rules #15 + + * #17). Consume via `classifyStampedPeerLocality()` below. + */ +export function resolveStampedViaProxy( + headerValue: string | null, + token: string | undefined +): boolean { + if (!headerValue || !token) return false; + const sep = headerValue.indexOf("|"); + if (sep <= 0) return false; + const provided = headerValue.slice(0, sep); + const payload = headerValue.slice(sep + 1); + if (provided.length !== token.length) return false; + try { + if (!timingSafeEqual(Buffer.from(provided), Buffer.from(token))) return false; + } catch { + return false; + } + return payload === "1"; +} + +/** + * The trusted locality verdict consumed by the LOCAL_ONLY route guard. Wraps + * `resolveStampedPeer()` + `resolveStampedViaProxy()` + `classifyHostLocality()` + * so the pipeline has a single boundary helper: + * + * 1. Resolve the real peer IP from PEER_IP_HEADER (or fail closed → remote). + * 2. If the via-proxy marker is present, the loopback / private-LAN socket + * is the proxy hop, not the end-user — downgrade to "remote". + * (Public-IP sockets are already remote, so the marker is a no-op there.) + * 3. Otherwise classify the raw IP normally (loopback / lan / remote). + * + * Pure; both header values are token-validated, so an attacker who knows the + * header names but not the per-process token cannot influence the verdict in + * any direction. + */ +export function classifyStampedPeerLocality( + peerHeader: string | null, + viaProxyHeader: string | null, + token: string | undefined +): "loopback" | "lan" | "remote" { + const ip = resolveStampedPeer(peerHeader, token); + const viaProxy = resolveStampedViaProxy(viaProxyHeader, token); + const locality = classifyHostLocality(ip); + if (viaProxy && locality !== "remote") return "remote"; + return locality; +} diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index 178ac5dd107a..81b039b49734 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -6,8 +6,7 @@ import { checkBodySize, getBodySizeLimit } from "../../shared/middleware/bodySiz import { generateRequestId } from "../../shared/utils/requestId"; import { applyCorsHeaders } from "../cors/origins"; import { classifyRoute } from "./classify"; -import { classifyHostLocality } from "./routeGuard"; -import { resolveStampedPeer } from "./peerStamp"; +import { classifyStampedPeerLocality } from "./peerStamp"; import { clientApiPolicy } from "./policies/clientApi"; import { managementPolicy } from "./policies/management"; import { publicPolicy } from "./policies/public"; @@ -21,6 +20,7 @@ import { AUTHZ_HEADER_ROUTE_CLASS, AUTHZ_TRUSTED_HEADERS, PEER_IP_HEADER, + VIA_PROXY_HEADER, } from "./headers"; import type { AuthSubject, RouteClass, RouteClassification } from "./types"; import type { AuthOutcome, RoutePolicy } from "./context"; @@ -232,21 +232,29 @@ export async function runAuthzPipeline( for (const trusted of AUTHZ_TRUSTED_HEADERS) { requestHeaders.delete(trusted); } - // The trusted peer-IP stamp is read by the policy from the ORIGINAL request - // (above); strip it from the forwarded headers so the per-process token never - // reaches route handlers or upstream providers. + // The trusted peer-IP + via-proxy stamps are read by the policy from the + // ORIGINAL request (above); strip them from the forwarded headers so the + // per-process token never reaches route handlers or upstream providers. requestHeaders.delete(PEER_IP_HEADER); + requestHeaders.delete(VIA_PROXY_HEADER); requestHeaders.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); requestHeaders.set(AUTHZ_HEADER_REQUEST_ID, requestId); // Stamp a trusted, non-secret locality verdict derived from the real stamped - // peer IP. Route handlers (e.g. cliTokenAuth) read this instead of re-deriving - // locality from the spoofable Host header. The client-supplied value (if any) - // was already removed by the AUTHZ_TRUSTED_HEADERS strip above. + // peer IP AND the via-proxy marker. Route handlers (e.g. cliTokenAuth) read + // this instead of re-deriving locality from the spoofable Host header. The + // client-supplied values (if any) were already removed by the + // AUTHZ_TRUSTED_HEADERS strip above. When the via-proxy marker is set, a + // loopback socket is the proxy hop, not the end-user — verdict is downgraded + // to "remote" so the LOCAL_ONLY gate is not bypassed by a request arriving + // through an external reverse proxy (nginx / Caddy / Cloudflare Tunnel). + // See peerStamp.ts and the upstream da667836 reference for the full rationale. requestHeaders.set( AUTHZ_HEADER_PEER_LOCALITY, - classifyHostLocality( - resolveStampedPeer(request.headers.get(PEER_IP_HEADER), process.env.OMNIROUTE_PEER_STAMP_TOKEN) + classifyStampedPeerLocality( + request.headers.get(PEER_IP_HEADER), + request.headers.get(VIA_PROXY_HEADER), + process.env.OMNIROUTE_PEER_STAMP_TOKEN ) ); diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index 42325876ae7c..9b518c35dd77 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -8,8 +8,8 @@ import { extractApiKey, isValidApiKey } from "../../../sse/services/auth"; import { getApiKeyMetadata } from "../../../lib/db/apiKeys"; import { hasManageScope } from "../../../lib/api/requireManagementAuth"; import { evaluateAccessTokenAuth } from "../accessTokenAuth"; -import { CLI_TOKEN_HEADER, PEER_IP_HEADER } from "../headers"; -import { resolveStampedPeer } from "../peerStamp"; +import { CLI_TOKEN_HEADER, PEER_IP_HEADER, VIA_PROXY_HEADER } from "../headers"; +import { resolveStampedPeer, resolveStampedViaProxy } from "../peerStamp"; import { isAlwaysProtectedPath, isLocalOnlyBypassableByManageScope, @@ -35,15 +35,34 @@ function requestPeerAddress(ctx: PolicyContext): string | null { return ctx.request.ip ?? ctx.request.socket?.remoteAddress ?? null; } +/** + * True when the inbound TCP request carried forwarding headers + * (`x-forwarded-for` / `x-real-ip`), as stamped by the custom Node server. When + * set, the socket peer is the reverse-proxy hop, not the end-user — so a + * loopback / private-LAN socket must NOT be trusted as local (Hard Rules #15 + + * #17, port of decolua/9router da667836). Token-validated; an attacker who + * knows the header name but not the per-process token cannot influence it. + */ +function isViaProxyRequest(ctx: PolicyContext): boolean { + return resolveStampedViaProxy( + ctx.request.headers?.get?.(VIA_PROXY_HEADER) ?? null, + process.env.OMNIROUTE_PEER_STAMP_TOKEN + ); +} + function isLoopbackRequest(ctx: PolicyContext): boolean { + if (isViaProxyRequest(ctx)) return false; const peerAddress = requestPeerAddress(ctx); return peerAddress ? isLoopbackHost(peerAddress) : false; } // Owner-authorized (2026-05-30): allow LOCAL_ONLY *paths* from a trusted private // LAN, based on the real socket peer IP (not spoofable). Does NOT relax the -// CLI-token gate, which stays strictly loopback. +// CLI-token gate, which stays strictly loopback. Also falls back to "not LAN" +// when a reverse-proxy hop is detected (the apparent LAN IP would be the proxy, +// not the end-user — see isViaProxyRequest above). function isPrivateLanRequest(ctx: PolicyContext): boolean { + if (isViaProxyRequest(ctx)) return false; const peerAddress = requestPeerAddress(ctx); return peerAddress ? isPrivateLanHost(peerAddress) : false; } diff --git a/tests/unit/route-guard-loopback-via-proxy.test.ts b/tests/unit/route-guard-loopback-via-proxy.test.ts new file mode 100644 index 000000000000..77552ec2600b --- /dev/null +++ b/tests/unit/route-guard-loopback-via-proxy.test.ts @@ -0,0 +1,112 @@ +/** + * Security regression: when OmniRoute itself runs behind an external reverse + * proxy (nginx / Caddy / Cloudflare Tunnel), `req.socket.remoteAddress` is the + * proxy hop — usually 127.0.0.1 — not the real end-user. + * + * Previously, the custom server stamped the loopback socket as the trusted + * peer IP, so `classifyHostLocality()` returned "loopback" for every remote + * caller arriving via the proxy → the LOCAL_ONLY route guard (which gates + * spawn-capable routes like `/api/cli-tools/runtime/*`, `/api/services/*`, + * `/api/plugins/*`, `/api/system/version`) was effectively bypassed. A leaked + * JWT over the public tunnel could trigger child-process spawning. + * + * Fix (mirrors upstream decolua/9router commit da667836): the custom server + * detects forwarding headers (`x-forwarded-for` / `x-real-ip`) and stamps a + * token-protected `via-proxy` marker. When the marker is present, locality + * derived from a loopback socket is downgraded to "remote" (fail closed). + * + * Hard Rules #15, #17 + Rule #18 (TDD before fix). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + resolveStampedPeer, + resolveStampedViaProxy, + classifyStampedPeerLocality, +} from "../../src/server/authz/peerStamp.ts"; + +const TOK = "process-secret-token-abc"; + +test("resolveStampedViaProxy: returns true only for the correctly-tokened stamp", () => { + assert.equal(resolveStampedViaProxy(`${TOK}|1`, TOK), true); + assert.equal(resolveStampedViaProxy(`${TOK}|0`, TOK), false); + assert.equal(resolveStampedViaProxy(null, TOK), false); + assert.equal(resolveStampedViaProxy("", TOK), false); +}); + +test("resolveStampedViaProxy: rejects forged / un-tokened values", () => { + assert.equal(resolveStampedViaProxy("1", TOK), false, "raw client value (no token)"); + assert.equal(resolveStampedViaProxy("wrong-token|1", TOK), false, "forged token"); + assert.equal(resolveStampedViaProxy(`${TOK}|1`, undefined), false, "no process token"); +}); + +test("classifyStampedPeerLocality: loopback socket WITHOUT a proxy stamp stays loopback", () => { + // Local CLI / dashboard hit — the normal happy path. + assert.equal( + classifyStampedPeerLocality(`${TOK}|127.0.0.1`, null, TOK), + "loopback" + ); + assert.equal(classifyStampedPeerLocality(`${TOK}|::1`, null, TOK), "loopback"); +}); + +test("classifyStampedPeerLocality: loopback socket WITH a proxy stamp is REMOTE (fail closed)", () => { + // OmniRoute is behind nginx/Caddy/Cloudflare; the socket peer is the proxy. + // The real end-user is somewhere on the public internet → must not be trusted + // as local, otherwise the LOCAL_ONLY spawn-capable surface is reachable from + // a tunnel. + assert.equal( + classifyStampedPeerLocality(`${TOK}|127.0.0.1`, `${TOK}|1`, TOK), + "remote", + "loopback socket + via-proxy stamp must NOT be classified as local" + ); + assert.equal( + classifyStampedPeerLocality(`${TOK}|::1`, `${TOK}|1`, TOK), + "remote" + ); + assert.equal( + classifyStampedPeerLocality(`${TOK}|::ffff:127.0.0.1`, `${TOK}|1`, TOK), + "remote" + ); +}); + +test("classifyStampedPeerLocality: private-LAN socket WITH a proxy stamp is still REMOTE", () => { + // Caddy/nginx running on a LAN box in front of OmniRoute. We do not know how + // the proxy is exposed (it could be tunneled to the public internet), so any + // proxy hop downgrades locality to remote. + assert.equal( + classifyStampedPeerLocality(`${TOK}|192.168.0.15`, `${TOK}|1`, TOK), + "remote" + ); +}); + +test("classifyStampedPeerLocality: public-IP socket is remote regardless of stamp", () => { + assert.equal( + classifyStampedPeerLocality(`${TOK}|8.8.8.8`, null, TOK), + "remote" + ); + assert.equal( + classifyStampedPeerLocality(`${TOK}|8.8.8.8`, `${TOK}|1`, TOK), + "remote" + ); +}); + +test("classifyStampedPeerLocality: missing / forged peer stamp fails closed to remote", () => { + assert.equal(classifyStampedPeerLocality(null, null, TOK), "remote"); + assert.equal(classifyStampedPeerLocality("forged|127.0.0.1", null, TOK), "remote"); +}); + +test("classifyStampedPeerLocality: untrusted (un-tokened) via-proxy header is ignored", () => { + // A remote attacker who guesses the via-proxy header name but cannot mint the + // token must NOT be able to flip the locality verdict by themselves; the + // safety direction is OK (downgrade), but the inverse — pretending no proxy + // exists when one does — would only be exploitable if the attacker controlled + // BOTH headers, which the token gate prevents. We assert the bypass attempt + // (un-tokened via-proxy hint) does not leak into the verdict for the normal + // local-CLI case. + assert.equal( + classifyStampedPeerLocality(`${TOK}|127.0.0.1`, "1", TOK), + "loopback", + "un-tokened via-proxy hint is ignored (== false) — local CLI keeps loopback" + ); +}); From b92f045f704daae648985ba9b7089321fae6537b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:01:00 -0300 Subject: [PATCH 036/120] fix(opencode): preserve DeepSeek reasoning content in streamed responses (#4631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — DeepSeek reasoning_content injection (port #1099); release-green --- open-sse/executors/opencode.ts | 14 ++- open-sse/utils/reasoningContentInjector.ts | 73 ++++++++++++++ ...encode-deepseek-reasoning-injected.test.ts | 94 +++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 open-sse/utils/reasoningContentInjector.ts create mode 100644 tests/unit/opencode-deepseek-reasoning-injected.test.ts diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index af0cf431b305..28666efb7696 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -7,6 +7,10 @@ import { } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; import { getModelTargetFormat } from "../config/providerModels.ts"; +import { + injectReasoningContentForThinkingModel, + isThinkingMessageModel, +} from "../utils/reasoningContentInjector.ts"; export class OpencodeExecutor extends BaseExecutor { _requestFormat: string | null = null; @@ -138,7 +142,7 @@ export class OpencodeExecutor extends BaseExecutor { stream: boolean, credentials: ProviderCredentials ): any { - const modifiedBody = super.transformRequest(model, body, stream, credentials); + let modifiedBody = super.transformRequest(model, body, stream, credentials); if ( modifiedBody && typeof modifiedBody === "object" && @@ -162,6 +166,14 @@ export class OpencodeExecutor extends BaseExecutor { } } } + // #1543 / upstream PR #1099: thinking-mode upstreams routed through OpenCode + // (DeepSeek V4 Flash, Kimi, MiniMax, ...) require reasoning_content echoed + // back on assistant messages, or they 400 with "reasoning_content must be + // passed back". OpenAI clients drop it across turns, so we inject a + // placeholder for the affected model families. + if (isThinkingMessageModel(model)) { + modifiedBody = injectReasoningContentForThinkingModel(modifiedBody); + } return modifiedBody; } } diff --git a/open-sse/utils/reasoningContentInjector.ts b/open-sse/utils/reasoningContentInjector.ts new file mode 100644 index 000000000000..8fd9b13bd412 --- /dev/null +++ b/open-sse/utils/reasoningContentInjector.ts @@ -0,0 +1,73 @@ +/** + * Thinking-mode upstreams (DeepSeek V4 Flash, Kimi, MiniMax, ...) require + * `reasoning_content` to be echoed back on every assistant message in the + * conversation history. Standard OpenAI clients do not preserve that field + * across turns, so we inject a non-empty placeholder before forwarding. + * + * Without the placeholder these upstreams return: + * 400 Bad Request — reasoning_content must be passed back + * + * Ported from decolua/9router#1099 (issue #1543). Pure helper — no I/O, no + * cross-module deps — kept narrow so it can be reused by other meta-providers + * that proxy to thinking-mode models. + */ + +const PLACEHOLDER = " "; + +type JsonRecord = Record; + +/** + * Model-id predicates for thinking-mode families that need the echo. + * Matched case-insensitively against the resolved model id (post upstream + * routing, e.g. `oc/deepseek-v4-flash-free` for the OpenCode meta-provider). + */ +const THINKING_MODEL_PATTERNS: RegExp[] = [ + /deepseek/i, + /\bkimi\b/i, + /\bk2\b/i, // moonshot kimi k2 family alias + /\bminimax\b/i, +]; + +export function isThinkingMessageModel(model: string | undefined | null): boolean { + if (!model || typeof model !== "string") return false; + return THINKING_MODEL_PATTERNS.some((re) => re.test(model)); +} + +function hasNonEmptyReasoningContent(message: JsonRecord): boolean { + return ( + typeof message.reasoning_content === "string" && + (message.reasoning_content as string).trim().length > 0 + ); +} + +function isAssistantMessage(value: unknown): value is JsonRecord { + return ( + !!value && + typeof value === "object" && + !Array.isArray(value) && + (value as JsonRecord).role === "assistant" + ); +} + +/** + * Inject a placeholder `reasoning_content` on every assistant message in + * `body.messages` that lacks one. Returns the original object if no mutation + * was needed, or a shallow-copied body with a new messages array otherwise. + * + * No-op when the body shape is unexpected (defensive). + */ +export function injectReasoningContentForThinkingModel(body: unknown): unknown { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const record = body as JsonRecord; + if (!Array.isArray(record.messages)) return body; + + let modified = false; + const messages = record.messages.map((message) => { + if (!isAssistantMessage(message)) return message; + if (hasNonEmptyReasoningContent(message)) return message; + modified = true; + return { ...message, reasoning_content: PLACEHOLDER }; + }); + + return modified ? { ...record, messages } : body; +} diff --git a/tests/unit/opencode-deepseek-reasoning-injected.test.ts b/tests/unit/opencode-deepseek-reasoning-injected.test.ts new file mode 100644 index 000000000000..9e98a6c6017a --- /dev/null +++ b/tests/unit/opencode-deepseek-reasoning-injected.test.ts @@ -0,0 +1,94 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +const { OpencodeExecutor } = await import("../../open-sse/executors/opencode.ts"); + +/** + * Regression test for upstream decolua/9router#1099 (issue #1543): + * + * OpenCode is a meta-provider that proxies multi-turn chats to thinking-mode + * upstreams (DeepSeek V4 Flash, Kimi, MiniMax, ...). Those upstreams require + * `reasoning_content` to be echoed back on every assistant message in the + * conversation history; otherwise they return: + * + * 400 Bad Request — reasoning_content must be passed back + * + * Standard OpenAI clients do not preserve `reasoning_content` across turns, so + * OpencodeExecutor.transformRequest must inject a non-empty placeholder on any + * assistant message that lacks it when routing to a thinking-mode model. + */ +describe("OpencodeExecutor — DeepSeek reasoning_content injection (#1099)", () => { + const executor = new OpencodeExecutor("opencode-zen"); + + function buildBody(extraAssistant: Record) { + return { + model: "oc/deepseek-v4-flash-free", + stream: true, + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "previous answer", ...extraAssistant }, + { role: "user", content: "follow up" }, + ], + }; + } + + it("injects reasoning_content on an assistant message that lacks it (deepseek model)", () => { + const out = executor.transformRequest( + "oc/deepseek-v4-flash-free", + buildBody({}), + true, + { apiKey: "test" } as never + ); + const assistant = out.messages.find((m: { role: string }) => m.role === "assistant"); + assert.equal(typeof assistant.reasoning_content, "string"); + assert.ok( + assistant.reasoning_content.length > 0, + "expected non-empty reasoning_content placeholder injected" + ); + }); + + it("preserves an existing non-empty reasoning_content instead of overwriting", () => { + const original = "prior chain of thought"; + const out = executor.transformRequest( + "oc/deepseek-v4-flash-free", + buildBody({ reasoning_content: original }), + true, + { apiKey: "test" } as never + ); + const assistant = out.messages.find((m: { role: string }) => m.role === "assistant"); + assert.equal(assistant.reasoning_content, original); + }); + + it("injects for kimi-family models routed through opencode", () => { + const body = { + model: "oc/kimi-k2-thinking", + stream: false, + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "answer" }, + ], + }; + const out = executor.transformRequest("oc/kimi-k2-thinking", body, false, { + apiKey: "test", + } as never); + const assistant = out.messages.find((m: { role: string }) => m.role === "assistant"); + assert.equal(typeof assistant.reasoning_content, "string"); + assert.ok(assistant.reasoning_content.length > 0); + }); + + it("does not inject for non-thinking opencode models (no false positives)", () => { + const body = { + model: "oc/gpt-5", + stream: false, + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "answer" }, + ], + }; + const out = executor.transformRequest("oc/gpt-5", body, false, { + apiKey: "test", + } as never); + const assistant = out.messages.find((m: { role: string }) => m.role === "assistant"); + assert.equal(assistant.reasoning_content, undefined); + }); +}); From 57de29a415071a28a17d0cce5d437192f848f372 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:02:29 -0300 Subject: [PATCH 037/120] fix(copilot,antigravity): cap maxOutputTokens at 16384 to stop "Invalid Argument" 400 (#4636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — cap maxOutputTokens 16384 antigravity (port #779); release-green --- config/quality/file-size-baseline.json | 3 +- open-sse/executors/antigravity.ts | 25 +++++ .../copilot-agent-antigravity-parity.test.ts | 96 +++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 tests/unit/copilot-agent-antigravity-parity.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index a4a08822d995..6747824f739e 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -19,6 +19,7 @@ "_rebaseline_2026_06_20_4023_web_cookie_noauth_validation": "PR #4023 (oyi77) own growth: src/lib/providers/validation.ts 4450->4518 (+68 = a new validateWebCookieProvider that probes the provider's /models endpoint — 401/403 => AUTH_007 SESSION_EXPIRED, any other status => valid session, empty cookie => invalid, provider-not-in-registry => unsupported — plus a local STANDARD_USER_AGENT const for the probe). Cohesive validator at the validateProviderApiKey dispatch; not extractable. Covered by tests/unit/provider-validation-web-cookie-auth007.test.ts. Heavily curated on merge — the PR's branch was badly stale-based (squash-base-stale), so its tree was DESTRUCTIVE: providers/index.ts deleted live providers openadapter/dit/tokenrouter (added by #4313) and the executor/base.ts edits reverted release fixes (#4037 duckduckgo host, theoldllm gpt5 models, base.ts fetch-start-timeout). Only the purely-additive validation feature was kept (validation.ts validateWebCookieProvider + errorCodes AUTH_007 + the test). Dropped: 5 malformed new registry entries (used non-RegistryEntry fields defaultModel/auth + referenced non-existent executors -> tsc TS2353), the destructive providers/index.ts + executor reverts, the unrelated pr-*.sh automation scripts, and evals/types.ts (belongs to the deferred evals modularization #4422). Also removed the PR's fragile 'Phase 2' executor probe (ran a live upstream chat during validation + classified any 'auth'-containing error as SESSION_EXPIRED) and rewrote the test to install its fetch mock before module load (the original mocked too late and silently hit live chatgpt.com).", "_rebaseline_2026_06_20_1308_model_lockout_honors_reset": "port from 9router#1308 own growth: open-sse/services/accountFallback.ts 1731->1752 (+21 = the new exported pure helper selectLockoutCooldownMs + its doc comment — picks the parsed upstream reset as the model-lockout exactCooldownMs when it exceeds the base cooldown, e.g. Antigravity \"Resets in 160h\", else preserves the existing 0/base behavior) and open-sse/executors/antigravity.ts 1680->1686 (this PR +1 = parseRetryFromErrorMessage regex `reset` -> `resets?` so plural \"Resets in 160h27m24s\" matches, plus a comment line; frozen set to the SUM 1686 with the concurrent #1944 which adds +5 at the disjoint passthroughFields region of the same file, so either merge order passes — pair-file rule). The combo lockout call sites in combo.ts now pass selectLockoutCooldownMs(cooldownMs, mlSettings) instead of always base/exponential, so an exhausted model honors the real upstream reset instead of being retried within minutes. Both edits are cohesive at the existing lockout/parse chokepoints; the helper is its own pure function (not extractable further). Covered by tests/unit/combo-model-lockout-honors-reset-1308.test.ts.", "_rebaseline_2026_06_20_1944_antigravity_strip_output_config": "port from 9router#1944: open-sse/executors/antigravity.ts frozen set to the measured cumulative 1687 of two concurrent PRs that touch disjoint regions of this file, so either merge order passes (pair-file rule). #1944 adds +6 at the envelope passthroughFields destructuring (~line 759: drop output_config/output_format — Anthropic/Claude-Code-only fields that Google's Cloud Code envelope rejects with `400 Unknown name \"output_config\"`, which broke every Claude model on Antigravity); #1308 adds +1 at parseRetryFromErrorMessage (~line 889: regex reset->resets?). Base 1680 + 6 + 1 = 1687 (re-measured on the real merge tip — the earlier 1686 estimate was off by one). Both edits are cohesive at their chokepoints; not extractable. Covered by tests/unit/antigravity-strip-output-config-1944.test.ts.", + "_rebaseline_2026_06_22_779_copilot_agent_antigravity_parity": "port from 9router#779 (@lukmanfauzie): open-sse/executors/antigravity.ts 1696->1721 (+25 = MAX_ANTIGRAVITY_OUTPUT_TOKENS constant + doc + final cap branch inside applyAntigravityGenerationDefaults + test-only export). Hard-caps generationConfig.maxOutputTokens at 16384 so VS Code GitHub Copilot Chat in Agent mode (which routinely requests 32K–65K tokens) stops triggering Antigravity upstream HTTP 400 'Invalid Argument'. The remaining items in upstream #779 (recursive JSON-schema sanitization, sanitizeFunctionName, $comment/enumDescriptions, functionResponse name resolution, VALIDATED mode) are already covered by OmniRoute's existing geminiHelper/geminiToolsSanitizer/openai-to-gemini pipeline — the cap is the only delta missing here. Cohesive guard at the existing generation-defaults chokepoint; not extractable. Covered by tests/unit/copilot-agent-antigravity-parity.test.ts.", "_rebaseline_2026_06_21_1926_antigravity_strip_thinking": "port from 9router#1926 (upstream fix PR decolua/9router#1949 by @Arcfoz): open-sse/executors/antigravity.ts 1687->1696 (+9 = 4 comment lines + 5 more destructured keys at the SAME envelope passthroughFields chokepoint the #1944 fix already owns). The unified thinking adapter can set Claude/OpenAI-native thinking fields (`thinking`, `reasoning_effort`, `reasoning`, `enable_thinking`, `thinking_budget`) at the body root; the envelope spreads `...passthroughFields`, so they leaked into the Google Cloud Code envelope and Google rejected the request with `400 Bad input: oneOf at '/' not met` (or `Unknown name \"thinking\"`), breaking every reasoning/thinking model served via Antigravity (e.g. claude-opus-4-x-thinking). Extends the existing destructuring strip to drop the whole thinking family. Cohesive at the same chokepoint as #1944; not extractable. Single PR (no concurrent pair-file this time). Covered by tests/unit/antigravity-strip-thinking-fields-1926.test.ts.", "_rebaseline_2026_06_20_1311_echo_requested_model": "port from 9router#1311 own growth: src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx 1580->1594 (+14 = one Settings → Routing card with a Toggle bound to the opt-in `echoRequestedModelName` setting, mirroring the adjacent LKGP/auto-routing toggles). When enabled, the response `model` field echoes the client-requested alias/combo name instead of the upstream model, so strict clients (Claude Desktop) that validate response.model === request.model stop rejecting with 401. The actual rewrite logic lives in the new pure leaf open-sse/services/responseModelEcho.ts (5137 reconciled after merging the current release base (base grew chatCore 5095->5120 via parallel merges; +~17 for the echoModel read + the two wiring chokepoints). The card is cohesive presentational wiring next to the existing toggles; not extractable. Covered by tests/unit/response-model-echo-1311.test.ts.", "_rebaseline_2026_06_21_4510_web_fetch_tool": "PR #4510 (@ponkcore) own growth: open-sse/mcp-server/schemas/tools.ts 1437->1497 (+60 = the omniroute_web_fetch tool definition — input/output Zod schema + description — added to MCP_TOOLS, mirroring omniroute_web_search) and open-sse/mcp-server/server.ts 1509->1555 (+46 = handleWebFetch handler + registerTool wiring with withScopeEnforcement scope execute:search + logToolCall/mcp_audit). Reconciled late: #4510 was MERGEABLE and squash-merged into release/v3.8.33; the quality.yml fast-gate on the PR did not flag file-size (it surfaced on the full tree check post-merge). Both edits are cohesive at the existing MCP tool-definition / tool-registration chokepoints, identical shape to the adjacent web_search tool; not extractable. Covered by tests/unit/mcp-web-fetch-tool.test.ts.", @@ -111,7 +112,7 @@ "frozen": { "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", "open-sse/config/providerRegistry.ts": 4731, - "open-sse/executors/antigravity.ts": 1696, + "open-sse/executors/antigravity.ts": 1721, "open-sse/executors/base.ts": 1414, "open-sse/executors/chatgpt-web.ts": 2870, "open-sse/executors/claude-web.ts": 1057, diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index b8b136673c18..da664006b5de 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -494,6 +494,17 @@ function getRequestTargetModel(body: Record): string { return typeof target === "string" && target.length > 0 ? target : "unknown"; } +/** + * Hard ceiling on `generationConfig.maxOutputTokens` for Antigravity Cloud Code. + * + * Ports decolua/9router#779 (lukmanfauzie): VS Code GitHub Copilot Chat in + * Agent mode regularly requests 32K–65K output tokens, which the Antigravity + * backend rejects with HTTP 400 "Invalid Argument". 16384 matches the + * upstream-accepted ceiling confirmed via successful 200 OK runs with + * claude-sonnet-4-6 and gemini-3.1-pro-high across both Ask and Agent modes. + */ +export const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384; + function applyAntigravityGenerationDefaults(request: Record): void { const generationConfig = request.generationConfig && typeof request.generationConfig === "object" @@ -521,9 +532,23 @@ function applyAntigravityGenerationDefaults(request: Record): v generationConfig.maxOutputTokens = Math.floor(thinkingBudget) + 1; } + // Final cap (after the thinkingBudget bump may have raised the value): + // GitHub Copilot Agent envelopes commonly carry oversized maxOutputTokens + // (32K–65K) that trigger upstream 400 "Invalid Argument". Clamp silently + // — the cap is provider-driven, not client-driven, and only matters when + // the request would otherwise be rejected outright. + const finalMax = Number(generationConfig.maxOutputTokens); + if (Number.isFinite(finalMax) && finalMax > MAX_ANTIGRAVITY_OUTPUT_TOKENS) { + generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS; + } + request.generationConfig = generationConfig; } +// Test-only export so the unit suite can exercise the cap logic in isolation +// without spinning up the full executor. +export const __test_applyAntigravityGenerationDefaults = applyAntigravityGenerationDefaults; + function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) diff --git a/tests/unit/copilot-agent-antigravity-parity.test.ts b/tests/unit/copilot-agent-antigravity-parity.test.ts new file mode 100644 index 000000000000..2f16c8bd28b1 --- /dev/null +++ b/tests/unit/copilot-agent-antigravity-parity.test.ts @@ -0,0 +1,96 @@ +// Port of decolua/9router#779: GitHub Copilot Agent Mode & Antigravity "Invalid Argument" parity. +// +// Upstream symptom: VS Code GitHub Copilot Chat (in Agent mode) requests +// `maxOutputTokens` values well above what the Antigravity Cloud Code backend +// will accept — the backend rejects the call with HTTP 400 "Invalid Argument" +// even though the request envelope is otherwise valid. The same trigger is +// repeatedly observed in upstream issue reports and in OmniRoute connection +// cooldowns spiking on Copilot traffic. +// +// Fix: hard-cap `generationConfig.maxOutputTokens` to MAX_ANTIGRAVITY_OUTPUT_TOKENS +// (16384) so any oversized client request is silently shrunk to a value the +// upstream accepts. Smaller values are left untouched, and the cap applies +// independently of the thinkingBudget bump logic that already lives in +// `applyAntigravityGenerationDefaults`. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + MAX_ANTIGRAVITY_OUTPUT_TOKENS, + __test_applyAntigravityGenerationDefaults as applyAntigravityGenerationDefaults, +} from "../../open-sse/executors/antigravity.ts"; + +test("MAX_ANTIGRAVITY_OUTPUT_TOKENS is fixed at 16384 (upstream-accepted ceiling)", () => { + assert.equal(MAX_ANTIGRAVITY_OUTPUT_TOKENS, 16384); +}); + +test("Copilot-style oversized maxOutputTokens is clamped to 16384 to avoid Antigravity 400 'Invalid Argument'", () => { + // Reproduces the upstream-rejected envelope: VS Code GitHub Copilot Chat in + // Agent mode commonly requests 32K–65K output tokens. + const request: Record = { + generationConfig: { + maxOutputTokens: 65536, + }, + }; + + applyAntigravityGenerationDefaults(request); + + const gc = request.generationConfig as Record; + assert.equal(gc.maxOutputTokens, MAX_ANTIGRAVITY_OUTPUT_TOKENS); +}); + +test("maxOutputTokens at or below the cap is left untouched", () => { + const request: Record = { + generationConfig: { + maxOutputTokens: 8192, + }, + }; + + applyAntigravityGenerationDefaults(request); + + const gc = request.generationConfig as Record; + assert.equal(gc.maxOutputTokens, 8192); +}); + +test("maxOutputTokens exactly at the cap is left untouched (boundary)", () => { + const request: Record = { + generationConfig: { + maxOutputTokens: MAX_ANTIGRAVITY_OUTPUT_TOKENS, + }, + }; + + applyAntigravityGenerationDefaults(request); + + const gc = request.generationConfig as Record; + assert.equal(gc.maxOutputTokens, MAX_ANTIGRAVITY_OUTPUT_TOKENS); +}); + +test("Cap is applied even when no generationConfig is provided initially", () => { + // The defaults helper synthesises generationConfig — the cap must not crash + // and must leave a well-formed object behind. + const request: Record = {}; + applyAntigravityGenerationDefaults(request); + + const gc = request.generationConfig as Record; + assert.equal(typeof gc, "object"); + // No maxOutputTokens was requested — the cap must not invent one. + assert.equal(gc.maxOutputTokens, undefined); +}); + +test("Cap interacts safely with thinkingBudget bump: bump still wins when budget exceeds tokens, then the cap still clamps the bumped value", () => { + // thinkingBudget bumps maxOutputTokens to floor(budget)+1 when it exceeds + // the requested ceiling; if the bump itself overshoots the AG cap, the cap + // must still apply. + const request: Record = { + generationConfig: { + maxOutputTokens: 1000, + thinkingConfig: { thinkingBudget: 20000 }, + }, + }; + + applyAntigravityGenerationDefaults(request); + + const gc = request.generationConfig as Record; + assert.equal(gc.maxOutputTokens, MAX_ANTIGRAVITY_OUTPUT_TOKENS); +}); From c02aa7b06448bfce603961ffbcd6e8833db8404b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:04:29 -0300 Subject: [PATCH 038/120] fix(dashboard): show custom vision models in LLM selector (#4653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — custom vision models in LLM selector (port 5e5e78d3); release-green --- config/quality/file-size-baseline.json | 2 +- src/app/api/v1/models/catalog.ts | 44 ++++++++++- .../llm-selector-custom-vision-models.test.ts | 74 +++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 tests/unit/llm-selector-custom-vision-models.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 6747824f739e..285a6e6633d9 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -190,7 +190,7 @@ "src/app/api/providers/[id]/models/route.ts": 2590, "src/app/api/providers/[id]/test/route.ts": 917, "src/app/api/usage/analytics/route.ts": 941, - "src/app/api/v1/models/catalog.ts": 1577, + "src/app/api/v1/models/catalog.ts": 1615, "src/lib/cloudflaredTunnel.ts": 934, "src/lib/db/apiKeys.ts": 1662, "src/lib/db/core.ts": 1825, diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 2cc2c90c9c58..e93dddd351b0 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -65,6 +65,12 @@ interface CustomModelEntry { supportedEndpoints?: string[]; inputTokenLimit?: number; isHidden?: boolean; + // User-set "vision-capable" flag (persisted by addCustomModel / replaceCustomModels + // in src/lib/db/models.ts). Surfaced into `/v1/models` via + // getCustomVisionCapabilityFields so user-added vision models appear with + // `capabilities.vision: true` even when their id does not match the + // conservative isVisionModelId heuristic. + supportsVision?: boolean; } const FALLBACK_ALIAS_TO_PROVIDER = { @@ -151,6 +157,39 @@ function getVisionCapabilityFields(modelId: string) { }; } +/** + * Vision-capability fields for a user-added custom chat model. Honours an + * explicit `supportsVision` flag on the saved entry (the dashboard "vision- + * capable" toggle) IN ADDITION TO the conservative id-based heuristic used by + * built-in models. Without this, a user who registered e.g. `my-vision-llm` + * and ticked vision saw no `capabilities.vision` in `/v1/models`, so the LLM + * selector and downstream routing treated the model as text-only. + * + * Port of upstream decolua/9router 5e5e78d3. Conservative: an explicit + * `supportsVision === false` wins so users can downgrade a mis-classified + * model (same anti-FP discipline as #4071 / #4072). + */ +export function getCustomVisionCapabilityFields( + entry: { supportsVision?: boolean } | null | undefined, + ...candidateIds: Array +): { capabilities: { vision: true }; input_modalities: string[]; output_modalities: string[] } | null { + if (entry && entry.supportsVision === false) return null; + if (entry && entry.supportsVision === true) { + return { + capabilities: { vision: true }, + input_modalities: ["text", "image"], + output_modalities: ["text"], + }; + } + for (const id of candidateIds) { + if (typeof id === "string" && id) { + const fields = getVisionCapabilityFields(id); + if (fields) return fields; + } + } + return null; +} + function qualifyOpenRouterModelId(modelId: string): string { return modelId.startsWith("openrouter/") ? modelId : `openrouter/${modelId}`; } @@ -1264,7 +1303,7 @@ export async function getUnifiedModelsResponse( } const visionFields = modelType === "chat" - ? getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(modelId) + ? getCustomVisionCapabilityFields(model, aliasId, modelId) : null; if (includeAlias) { @@ -1297,8 +1336,7 @@ export async function getUnifiedModelsResponse( if (models.some((m) => m.id === providerPrefixedId)) continue; const providerVisionFields = modelType === "chat" - ? getVisionCapabilityFields(providerPrefixedId) || - getVisionCapabilityFields(modelId) + ? getCustomVisionCapabilityFields(model, providerPrefixedId, modelId) : null; models.push({ id: providerPrefixedId, diff --git a/tests/unit/llm-selector-custom-vision-models.test.ts b/tests/unit/llm-selector-custom-vision-models.test.ts new file mode 100644 index 000000000000..3d79da12fd49 --- /dev/null +++ b/tests/unit/llm-selector-custom-vision-models.test.ts @@ -0,0 +1,74 @@ +/** + * Custom vision models in the LLM selector / `/v1/models` catalog + * (port of upstream decolua/9router 5e5e78d3 — "fix: show custom vision + * models in LLM selector and model list"). + * + * Upstream stored an `imageToText` kind on user-added custom models and the + * selector hid them from the default LLM list. OmniRoute already keeps custom + * chat models in the LLM list, but the equivalent gap is the `supportsVision` + * flag on a custom model: the catalog used to only set `capabilities.vision` + * when the model id matched the conservative `isVisionModelId` heuristic + * (`pixtral`, `llava`, `qwen-vl`, …). A user who registered a custom chat + * model called e.g. `my-vision-llm` and explicitly checked "vision-capable" + * still saw no `capabilities.vision` in `/v1/models`, so the LLM selector and + * downstream routing treated it as text-only. + * + * This file pins the OmniRoute-flavoured behaviour: + * • a custom chat model with `supportsVision: true` MUST surface + * `capabilities: { vision: true }` via the catalog helper, regardless of + * whether the model id matches the static vision heuristic. + * • the existing id-based heuristic (`isVisionModelId`) still works. + * • when neither signal is present, no vision fields are emitted (no false + * positives — same conservative guard #4071 / #4072). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const catalog = await import("../../src/app/api/v1/models/catalog.ts"); + +type VisionFields = { capabilities: { vision: true } } | null; +type Helper = (entry: unknown, ...candidateIds: Array) => VisionFields; + +const getCustomVisionCapabilityFields = ( + catalog as unknown as { getCustomVisionCapabilityFields?: Helper } +).getCustomVisionCapabilityFields; + +test("custom vision helper is exported from the catalog module", () => { + assert.equal( + typeof getCustomVisionCapabilityFields, + "function", + "expected getCustomVisionCapabilityFields to be exported from src/app/api/v1/models/catalog.ts" + ); +}); + +test("supportsVision flag on a custom model surfaces capabilities.vision (LLM selector unblock)", () => { + const helper = getCustomVisionCapabilityFields as Helper; + const entry = { id: "my-vision-llm", supportsVision: true }; + const fields = helper(entry, "openai-compat/my-vision-llm", "my-vision-llm"); + assert.ok(fields, "supportsVision=true must yield vision fields"); + assert.equal(fields?.capabilities.vision, true); +}); + +test("id-based isVisionModelId heuristic still works when supportsVision is unset", () => { + const helper = getCustomVisionCapabilityFields as Helper; + const entry = { id: "pixtral-12b" }; + const fields = helper(entry, "openai-compat/pixtral-12b", "pixtral-12b"); + assert.ok(fields, "well-known vision model id must yield vision fields"); + assert.equal(fields?.capabilities.vision, true); +}); + +test("no signal → no vision fields (avoids #4071-style false positives)", () => { + const helper = getCustomVisionCapabilityFields as Helper; + const entry = { id: "plain-text-llm" }; + const fields = helper(entry, "openai-compat/plain-text-llm", "plain-text-llm"); + assert.equal(fields, null); +}); + +test("explicit supportsVision=false is respected (text-only override)", () => { + const helper = getCustomVisionCapabilityFields as Helper; + // Even if the id matches the heuristic, an explicit false flag wins so the + // user can downgrade a mis-classified model. + const entry = { id: "pixtral-12b", supportsVision: false }; + const fields = helper(entry, "alias/pixtral-12b", "pixtral-12b"); + assert.equal(fields, null); +}); From 6fe21e59e9a4e5d7ea3059194a78a912c4a0c243 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:06:59 -0300 Subject: [PATCH 039/120] fix(claude): omit adaptive thinking + output_config.effort for haiku (#4661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — haiku adaptive-thinking omit (port); release-green --- open-sse/handlers/chatCore.ts | 8 ++ open-sse/services/claudeHaikuConstraints.ts | 88 +++++++++++++++++ .../claude-haiku-no-adaptive-thinking.test.ts | 99 +++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 open-sse/services/claudeHaikuConstraints.ts create mode 100644 tests/unit/claude-haiku-no-adaptive-thinking.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 5f3c50f872f6..6f5cbe4035a4 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -93,6 +93,7 @@ import { import { resolveModelAlias } from "../services/modelDeprecation.ts"; import { normalizeMimoThinking } from "../services/mimoThinking.ts"; import { normalizeClaudeAdaptiveThinking } from "../services/claudeAdaptiveThinking.ts"; +import { normalizeClaudeHaikuConstraints } from "../services/claudeHaikuConstraints.ts"; import { echoModelInObject } from "../services/responseModelEcho.ts"; import { stripGpt5SamplingWhenReasoning } from "../services/gpt5SamplingGuard.ts"; import { getUnsupportedParams } from "../config/providerRegistry.ts"; @@ -1796,6 +1797,13 @@ export async function handleChatCore({ // defaults) to `{type:"adaptive"}` — effort stays on `output_config.effort`. Keyed on // the resolved upstream model, so it covers every routing mode. See claudeAdaptiveThinking.ts. translatedBody = normalizeClaudeAdaptiveThinking(translatedBody, finalModelToUpstream); + // Claude Haiku rejects `thinking.type:"adaptive"` and `output_config.effort` + // (both Sonnet 4.6 / Opus 4.5+ only). Several paths can still emit those + // shapes on a Haiku target — native passthrough, reasoning_effort buckets, + // per-model defaults — so collapse them to a Haiku-valid shape here, after + // model substitution. Mirrors upstream 9router 401d93bd5. See + // services/claudeHaikuConstraints.ts. + translatedBody = normalizeClaudeHaikuConstraints(translatedBody, finalModelToUpstream); } // Xiaomi MiMo controls reasoning ONLY via `thinking:{type:"enabled"|"disabled"}` and diff --git a/open-sse/services/claudeHaikuConstraints.ts b/open-sse/services/claudeHaikuConstraints.ts new file mode 100644 index 000000000000..23c6f912dad5 --- /dev/null +++ b/open-sse/services/claudeHaikuConstraints.ts @@ -0,0 +1,88 @@ +type JsonRecord = Record; + +// Models that reject `thinking.type:"adaptive"` and `output_config.effort` — +// today, all Claude Haiku-tier models (4.5 / 3.5 / dated and aliased). Adaptive +// thinking + the effort knob landed on Sonnet 4.6 / Opus 4.5+ only; sending +// either on Haiku is a hard 400 from the Messages API. +const HAIKU_CONSTRAINT_PATTERN = /haiku/i; + +// Default budget when collapsing `thinking.type:"adaptive"` to a manual shape +// on Haiku. Mirrors the upstream 9router decision (`claude.js`, commit +// 401d93bd5) — a conservative ~10K budget keeps reasoning enabled without +// hitting Haiku's output-token cap. +const HAIKU_FALLBACK_THINKING_BUDGET = 10000; + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function modelRejectsAdaptiveAndEffort(modelId: string | null | undefined): boolean { + if (typeof modelId !== "string" || modelId.length === 0) return false; + return HAIKU_CONSTRAINT_PATTERN.test(modelId); +} + +/** + * Strip Claude-API params that Haiku-tier models reject. + * + * Anthropic restricts two extended-reasoning knobs to Sonnet 4.6 / Opus 4.5+: + * 1. `thinking.type:"adaptive"` — Haiku only accepts manual `"enabled"` (with a + * `budget_tokens`) or `"disabled"`. + * 2. `output_config.effort` — only the larger models steer reasoning via effort. + * Sending either of these to Haiku is a hard `400` from the Messages API. + * + * Multiple OmniRoute paths can still emit those shapes on a Haiku target: + * - native Claude passthrough from newer Claude Code / Cowork clients; + * - the OpenAI→Claude translator when `reasoning_effort` is `max`/`xhigh` + * (see `request/openai-to-claude.ts`) — Haiku isn't `adaptiveThinkingOnly`, + * so it falls into the branch that emits `{type:"adaptive"}` + `effort`; + * - per-model thinking defaults from the request flow / combo routing. + * + * This normalizer is the final, provider-agnostic guard keyed on the resolved + * upstream model. It runs after model substitution in `chatCore.ts`, so it + * covers every routing mode (single-model, combo, fallback). + * + * Returns a NEW object only when it changes the body. No-op for non-Haiku + * models, when the body carries no thinking/output_config, or when neither + * `thinking.type:"adaptive"` nor `output_config.effort` is present — so the + * existing Sonnet/Opus paths and Haiku bodies without these fields are + * unaffected. + * + * Mirrors upstream 9router commit 401d93bd5 (`open-sse/translator/formats/claude.js`). + */ +export function normalizeClaudeHaikuConstraints>( + body: T, + model: string | null | undefined +): T { + if (!modelRejectsAdaptiveAndEffort(model)) return body; + const record = asRecord(body); + if (!record) return body; + + const thinking = asRecord(record.thinking); + const outputConfig = asRecord(record.output_config); + + const needsThinkingRewrite = thinking?.type === "adaptive"; + const needsEffortStrip = outputConfig != null && outputConfig.effort != null; + if (!needsThinkingRewrite && !needsEffortStrip) return body; + + const next: JsonRecord = { ...record }; + + if (needsThinkingRewrite && thinking) { + next.thinking = { + ...thinking, + type: "enabled", + budget_tokens: HAIKU_FALLBACK_THINKING_BUDGET, + }; + } + + if (needsEffortStrip && outputConfig) { + const nextOutputConfig: JsonRecord = { ...outputConfig }; + delete nextOutputConfig.effort; + if (Object.keys(nextOutputConfig).length === 0) { + delete next.output_config; + } else { + next.output_config = nextOutputConfig; + } + } + + return next as T; +} diff --git a/tests/unit/claude-haiku-no-adaptive-thinking.test.ts b/tests/unit/claude-haiku-no-adaptive-thinking.test.ts new file mode 100644 index 000000000000..56c7b6bc332f --- /dev/null +++ b/tests/unit/claude-haiku-no-adaptive-thinking.test.ts @@ -0,0 +1,99 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { normalizeClaudeHaikuConstraints } from "@omniroute/open-sse/services/claudeHaikuConstraints.ts"; + +// Claude Haiku 4.5 (and other haiku-tier models) reject: +// 1. `thinking.type:"adaptive"` (only Sonnet/Opus support adaptive thinking) +// 2. `output_config.effort` (also Sonnet/Opus only) +// Several OmniRoute paths can still emit these on Haiku targets: +// - Native Claude passthrough from newer Claude Code / Cowork clients +// - OpenAI→Claude translator buckets when reasoning_effort is "max"/"xhigh" +// - Per-model thinking defaults from the request flow +// This normalizer is the final, provider-agnostic guard keyed on the resolved +// upstream model. Mirrors upstream 9router 401d93bd5 (`claude.js`). + +describe("normalizeClaudeHaikuConstraints", () => { + it("converts thinking.type:'adaptive' to a manual 'enabled' shape on haiku", () => { + const input = { + model: "claude-haiku-4-5-20251001", + thinking: { type: "adaptive" }, + }; + const out = normalizeClaudeHaikuConstraints(input, input.model); + assert.deepEqual(out.thinking, { type: "enabled", budget_tokens: 10000 }); + }); + + it("strips output_config.effort on haiku and keeps other output_config fields", () => { + const input = { + model: "claude-haiku-4.5", + output_config: { effort: "high", other_field: "keep-me" }, + }; + const out = normalizeClaudeHaikuConstraints(input, input.model); + assert.deepEqual(out.output_config, { other_field: "keep-me" }); + }); + + it("removes output_config entirely when stripping effort leaves it empty", () => { + const input = { + model: "claude-haiku-4-5-20251001", + output_config: { effort: "max" }, + }; + const out = normalizeClaudeHaikuConstraints(input, input.model); + assert.equal((out as Record).output_config, undefined); + }); + + it("applies BOTH transforms when the body carries adaptive thinking + effort", () => { + const input = { + model: "claude-haiku-4.5", + thinking: { type: "adaptive" }, + output_config: { effort: "xhigh" }, + }; + const out = normalizeClaudeHaikuConstraints(input, input.model); + assert.deepEqual(out.thinking, { type: "enabled", budget_tokens: 10000 }); + assert.equal((out as Record).output_config, undefined); + }); + + it("is a no-op for Sonnet (adaptive is valid there)", () => { + const input = { + model: "claude-sonnet-4-5", + thinking: { type: "adaptive" }, + output_config: { effort: "high" }, + }; + const out = normalizeClaudeHaikuConstraints(input, input.model); + assert.strictEqual(out, input); + }); + + it("is a no-op for Opus (adaptive is valid there)", () => { + const input = { + model: "claude-opus-4-7", + thinking: { type: "adaptive" }, + output_config: { effort: "max" }, + }; + const out = normalizeClaudeHaikuConstraints(input, input.model); + assert.strictEqual(out, input); + }); + + it("leaves haiku bodies that have no adaptive thinking or effort untouched", () => { + const input = { + model: "claude-haiku-4-5-20251001", + thinking: { type: "enabled", budget_tokens: 4096 }, + messages: [{ role: "user", content: "hi" }], + }; + const out = normalizeClaudeHaikuConstraints(input, input.model); + assert.strictEqual(out, input); + }); + + it("returns the body untouched when model is null/undefined", () => { + const input = { thinking: { type: "adaptive" }, output_config: { effort: "high" } }; + assert.strictEqual(normalizeClaudeHaikuConstraints(input, null), input); + assert.strictEqual(normalizeClaudeHaikuConstraints(input, undefined), input); + }); + + it("matches case-insensitively (any model id containing 'haiku')", () => { + const input = { + model: "claude-3-5-Haiku-latest", + thinking: { type: "adaptive" }, + }; + const out = normalizeClaudeHaikuConstraints(input, input.model); + assert.deepEqual(out.thinking, { type: "enabled", budget_tokens: 10000 }); + }); +}); From 2d09f72b291121b21124dfee6aa5740dc222cad1 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:09:04 -0300 Subject: [PATCH 040/120] =?UTF-8?q?feat(provider):=20CodeBuddy=20CN=20(cop?= =?UTF-8?q?ilot.tencent.com)=20=E2=80=94=20full=20stack=20(#4664)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — CodeBuddy CN provider (port efd20be8); usage.ts import + public-creds allowlist line reconciled; release-green --- config/quality/file-size-baseline.json | 10 +- open-sse/config/providers/index.ts | 2 + .../providers/registry/codebuddy-cn/index.ts | 151 ++++++++++ open-sse/executors/codebuddy-cn.ts | 48 ++++ open-sse/executors/index.ts | 4 + open-sse/services/tokenRefresh.ts | 73 +++++ open-sse/services/usage.ts | 4 + open-sse/services/usage/codebuddy-cn.ts | 199 +++++++++++++ scripts/check/check-public-creds.mjs | 4 +- .../api/oauth/[provider]/[action]/route.ts | 10 +- src/app/api/providers/[id]/test/route.ts | 7 + src/lib/oauth/constants/oauth.ts | 14 + src/lib/oauth/providers/codebuddy-cn.ts | 123 ++++++++ src/lib/oauth/providers/index.ts | 2 + src/shared/components/OAuthModal.tsx | 3 +- src/shared/components/lobeProviderIcons.ts | 1 + src/shared/constants/providers.ts | 24 +- tests/unit/codebuddy-cn-provider.test.ts | 262 ++++++++++++++++++ 18 files changed, 930 insertions(+), 11 deletions(-) create mode 100644 open-sse/config/providers/registry/codebuddy-cn/index.ts create mode 100644 open-sse/executors/codebuddy-cn.ts create mode 100644 open-sse/services/usage/codebuddy-cn.ts create mode 100644 src/lib/oauth/providers/codebuddy-cn.ts create mode 100644 tests/unit/codebuddy-cn-provider.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 285a6e6633d9..8229ee41ca99 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -143,8 +143,8 @@ "open-sse/services/combo.ts": 3036, "open-sse/services/compression/strategySelector.ts": 848, "open-sse/services/rateLimitManager.ts": 1035, - "open-sse/services/tokenRefresh.ts": 1997, - "open-sse/services/usage.ts": 3450, + "open-sse/services/tokenRefresh.ts": 2070, + "open-sse/services/usage.ts": 3454, "open-sse/translator/request/openai-to-gemini.ts": 864, "open-sse/translator/request/openai-to-kiro.ts": 807, "open-sse/translator/response/openai-responses.ts": 922, @@ -186,9 +186,9 @@ "src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1016, "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148, "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1121, - "src/app/api/oauth/[provider]/[action]/route.ts": 918, + "src/app/api/oauth/[provider]/[action]/route.ts": 924, "src/app/api/providers/[id]/models/route.ts": 2590, - "src/app/api/providers/[id]/test/route.ts": 917, + "src/app/api/providers/[id]/test/route.ts": 924, "src/app/api/usage/analytics/route.ts": 941, "src/app/api/v1/models/catalog.ts": 1615, "src/lib/cloudflaredTunnel.ts": 934, @@ -213,7 +213,7 @@ "src/shared/components/analytics/charts.tsx": 1558, "src/shared/constants/cliTools.ts": 875, "src/shared/constants/pricing.ts": 1662, - "src/shared/constants/providers.ts": 3254, + "src/shared/constants/providers.ts": 3276, "src/shared/constants/sidebarVisibility.ts": 1100, "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2523, diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 21a74252fc92..ef02d0697f77 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -166,6 +166,7 @@ import { kiroProvider } from "./registry/kiro/index.ts"; import { openadapterProvider } from "./registry/openadapter/index.ts"; import { ditProvider } from "./registry/dit/index.ts"; import { tokenrouterProvider } from "./registry/tokenrouter/index.ts"; +import { codebuddy_cnProvider } from "./registry/codebuddy-cn/index.ts"; export const REGISTRY: Record = { aimlapi: aimlapiProvider, @@ -334,4 +335,5 @@ export const REGISTRY: Record = { openadapter: openadapterProvider, dit: ditProvider, tokenrouter: tokenrouterProvider, + "codebuddy-cn": codebuddy_cnProvider, }; diff --git a/open-sse/config/providers/registry/codebuddy-cn/index.ts b/open-sse/config/providers/registry/codebuddy-cn/index.ts new file mode 100644 index 000000000000..e72492a029f2 --- /dev/null +++ b/open-sse/config/providers/registry/codebuddy-cn/index.ts @@ -0,0 +1,151 @@ +import type { RegistryEntry } from "../../shared.ts"; + +/** + * CodeBuddy CN (Tencent) — copilot.tencent.com. + * + * Unified OpenAI-compatible gateway behind Tencent's CodeBuddy CLI. Carries 15 + * models (GLM, Kimi, MiniMax, DeepSeek, Hunyuan) and reasons via OpenAI-style + * `reasoning_effort` (not vendor-native thinking shapes). Streaming is forced + * by the executor because non-stream requests are rejected with code 11101. + * + * Short alias "cbcn" reserves "cbai" for a future CodeBuddy intl variant. + * Per-model context windows/vision are advertised in the catalog below. + */ +export const codebuddy_cnProvider: RegistryEntry = { + id: "codebuddy-cn", + alias: "cbcn", + format: "openai", + executor: "codebuddy-cn", + baseUrl: "https://copilot.tencent.com/v2/chat/completions", + authType: "oauth", + authHeader: "bearer", + headers: { + "User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1", + "X-Product": "SaaS", + "X-IDE-Type": "CLI", + "X-IDE-Name": "CLI", + "x-requested-with": "XMLHttpRequest", + "x-codebuddy-request": "1", + }, + models: [ + // contextLength is the OmniRoute analogue of upstream's contextWindow; + // supportsReasoning + supportsVision drive UI affordances and translator + // decisions. Sourced from the gateway's model config. + { + id: "glm-5.2", + name: "GLM-5.2", + contextLength: 1000000, + maxOutputTokens: 48000, + supportsReasoning: true, + }, + { + id: "glm-5.1", + name: "GLM-5.1", + contextLength: 200000, + maxOutputTokens: 48000, + supportsReasoning: true, + }, + { + id: "glm-5.0", + name: "GLM-5.0", + contextLength: 200000, + maxOutputTokens: 48000, + supportsReasoning: true, + }, + { + id: "glm-5.0-turbo", + name: "GLM-5.0-Turbo", + contextLength: 200000, + maxOutputTokens: 48000, + supportsReasoning: true, + }, + { + id: "glm-5v-turbo", + name: "GLM-5v-Turbo", + contextLength: 200000, + maxOutputTokens: 38000, + supportsReasoning: true, + supportsVision: true, + }, + { + id: "glm-4.7", + name: "GLM-4.7", + contextLength: 200000, + maxOutputTokens: 48000, + supportsReasoning: true, + }, + { + id: "minimax-m3", + name: "MiniMax-M3", + contextLength: 512000, + maxOutputTokens: 48000, + supportsReasoning: true, + supportsVision: true, + }, + { + id: "minimax-m2.7", + name: "MiniMax-M2.7", + contextLength: 200000, + maxOutputTokens: 48000, + supportsReasoning: true, + supportsVision: true, + }, + { + id: "kimi-k2.7", + name: "Kimi-K2.7-Code", + contextLength: 256000, + maxOutputTokens: 32000, + supportsReasoning: true, + supportsVision: true, + }, + { + id: "kimi-k2.6", + name: "Kimi-K2.6", + contextLength: 256000, + maxOutputTokens: 32000, + supportsReasoning: true, + supportsVision: true, + }, + { + id: "kimi-k2.5", + name: "Kimi-K2.5", + contextLength: 164000, + maxOutputTokens: 32000, + supportsReasoning: true, + supportsVision: true, + }, + { + id: "hy3-preview", + name: "Hy3 Preview", + contextLength: 192000, + maxOutputTokens: 64000, + supportsReasoning: true, + supportsVision: true, + }, + { + id: "deepseek-v4-pro", + name: "DeepSeek-V4-Pro", + contextLength: 1000000, + maxOutputTokens: 50000, + supportsReasoning: true, + supportsVision: true, + }, + { + id: "deepseek-v4-flash", + name: "DeepSeek-V4-Flash", + contextLength: 1000000, + maxOutputTokens: 50000, + supportsReasoning: true, + supportsVision: true, + }, + { + id: "deepseek-v3-2-volc", + name: "DeepSeek-V3.2", + contextLength: 96000, + maxOutputTokens: 32000, + supportsReasoning: true, + }, + ], +}; + +export default codebuddy_cnProvider; diff --git a/open-sse/executors/codebuddy-cn.ts b/open-sse/executors/codebuddy-cn.ts new file mode 100644 index 000000000000..83840ca25421 --- /dev/null +++ b/open-sse/executors/codebuddy-cn.ts @@ -0,0 +1,48 @@ +import { DefaultExecutor } from "./default.ts"; +import type { ProviderCredentials } from "./base.ts"; + +/** + * CodeBuddyCnExecutor — talks to https://copilot.tencent.com/v2/chat/completions + * + * CodeBuddy CN is an OpenAI-compatible Tencent gateway but it rejects non-stream + * chat requests (HTTP 400, code 11101 "Non-stream chat request is currently not + * supported"). The same-format (openai→openai) translator path leaves body.stream + * as the client sent it, so we force it true here — OmniRoute still re-aggregates + * the SSE into a JSON response for non-streaming clients. + * + * CodeBuddy CN only surfaces model reasoning when the request carries the + * official CLI's OpenAI-style params: reasoning_effort + reasoning_summary:"auto". + * Mirror the CLI here. When the caller explicitly asks for "none"/"off" we drop + * the field entirely (the gateway has no "none" value). + */ +export class CodeBuddyCnExecutor extends DefaultExecutor { + constructor() { + super("codebuddy-cn"); + } + + transformRequest( + model: string, + body: unknown, + stream: boolean, + credentials: ProviderCredentials + ): unknown { + const transformed = super.transformRequest(model, body, stream, credentials); + if (!transformed || typeof transformed !== "object" || Array.isArray(transformed)) { + return transformed; + } + const out = transformed as Record; + out.stream = true; + + const eff = out.reasoning_effort; + if (eff === "none" || eff === "off") { + // Gateway has no "none" — just omit. Do NOT set reasoning_summary. + delete out.reasoning_effort; + } else { + if (!eff) out.reasoning_effort = "medium"; + out.reasoning_summary = "auto"; + } + return out; + } +} + +export default CodeBuddyCnExecutor; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index cc13453ec955..3dc638711fc0 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -52,6 +52,7 @@ import { TheOldLlmExecutor } from "./theoldllm.ts"; import { ChipotleExecutor } from "./chipotle.ts"; import { LMArenaExecutor } from "./lmarena.ts"; import { MimocodeExecutor } from "./mimocode.ts"; +import { CodeBuddyCnExecutor } from "./codebuddy-cn.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -148,6 +149,8 @@ const executors = { lma: new LMArenaExecutor(), // Alias mimocode: new MimocodeExecutor(), mcode: new MimocodeExecutor(), // Alias + "codebuddy-cn": new CodeBuddyCnExecutor(), + cbcn: new CodeBuddyCnExecutor(), // Alias for codebuddy-cn }; const defaultCache = new Map(); @@ -208,3 +211,4 @@ export { TheOldLlmExecutor } from "./theoldllm.ts"; export { ChipotleExecutor } from "./chipotle.ts"; export { LMArenaExecutor } from "./lmarena.ts"; export { MimocodeExecutor } from "./mimocode.ts"; +export { CodeBuddyCnExecutor } from "./codebuddy-cn.ts"; diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 0581a77bf6b6..231bbb8ee166 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -463,6 +463,75 @@ export async function refreshWindsurfToken( } } +/** + * CodeBuddy CN (Tencent) token refresh — POST /v2/plugin/auth/token/refresh with + * the refresh token carried in the X-Refresh-Token header (not a form body), + * matching the official CodeBuddy CLI. Response: { code: 0, data: }. + */ +export async function refreshCodebuddyCnToken( + refreshToken: string, + log: any, + proxyConfig: unknown = null +) { + if (!refreshToken) return null; + const { CODEBUDDY_CN_CONFIG } = await import("@/lib/oauth/constants/oauth"); + const oauth = CODEBUDDY_CN_CONFIG; + try { + const response = await runWithProxyContext(proxyConfig, () => + fetch(oauth.refreshUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": oauth.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "copilot.tencent.com", + "X-Refresh-Token": refreshToken, + "X-Auth-Refresh-Source": "plugin", + "X-Product": "SaaS", + }, + body: "{}", + }) + ); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh CodeBuddy CN token", { + status: response.status, + error: errorText, + }); + return null; + } + + const data: any = await response.json(); + if (data?.code !== 0 || !data?.data?.accessToken) { + log?.error?.("TOKEN_REFRESH", "CodeBuddy CN token refresh returned no token", { + code: data?.code, + msg: data?.msg, + }); + return null; + } + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed CodeBuddy CN token", { + hasNewAccessToken: !!data.data.accessToken, + hasNewRefreshToken: !!data.data.refreshToken, + expiresIn: data.data.expiresIn, + }); + + return { + accessToken: data.data.accessToken, + refreshToken: data.data.refreshToken || refreshToken, + expiresIn: data.data.expiresIn, + }; + } catch (error: any) { + log?.error?.( + "TOKEN_REFRESH", + `Network error refreshing CodeBuddy CN token: ${error?.message}` + ); + return null; + } +} + export async function refreshClineToken(refreshToken, log, proxyConfig: unknown = null) { const endpoint = PROVIDERS.cline?.refreshUrl; if (!endpoint) { @@ -1457,6 +1526,9 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: proxyConfig ); + case "codebuddy-cn": + return await refreshCodebuddyCnToken(credentials.refreshToken, log, proxyConfig); + default: // Fallback to generic OAuth refresh for unknown providers return refreshAccessToken(provider, credentials.refreshToken, credentials, log, proxyConfig); @@ -1484,6 +1556,7 @@ export function supportsTokenRefresh(provider) { "windsurf", "devin-cli", "gitlab-duo", + "codebuddy-cn", ]); if (explicitlySupported.has(provider)) return true; const config = PROVIDERS[provider]; diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 5be8ab269291..296f47ab8ea5 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -21,6 +21,7 @@ import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuota import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts"; import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "./opencodeQuotaFetcher.ts"; import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.ts"; +import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.ts"; import { applyAntigravityClientProfileHeaders, getAntigravityBootstrapHeaders, @@ -1336,6 +1337,7 @@ export const USAGE_FETCHER_PROVIDERS = [ "xiaomi-mimo", "vertex", "vertex-partner", + "codebuddy-cn", ] as const; export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number]; @@ -1414,6 +1416,8 @@ export async function getUsageForProvider( return await getOpencodeUsage(id || "", apiKey || ""); case "xiaomi-mimo": return await getXiaomiMimoUsage(id || ""); + case "codebuddy-cn": + return await getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData); default: return { message: `Usage API not implemented for ${provider}` }; } diff --git a/open-sse/services/usage/codebuddy-cn.ts b/open-sse/services/usage/codebuddy-cn.ts new file mode 100644 index 000000000000..27b1df83fdd1 --- /dev/null +++ b/open-sse/services/usage/codebuddy-cn.ts @@ -0,0 +1,199 @@ +/** + * CodeBuddy CN usage handler — scoped to the "codebuddy-cn" provider. + * + * Quota lives behind a Tencent billing endpoint (POST, payload wrapped twice + * under data.Response.Data). It mixes two credit types that must NOT be merged: + * + * - Refill / base ("基础体验包"): recurring allowance whose cycle resets long + * before the resource itself expires (CycleEndTime << DeductionEndTime). + * Live numbers in the *Cycle* fields; resetAt = next refresh. + * - Bonus ("活动赠送包"): one-shot credits that run a single cycle and expire + * (CycleEndTime ≈ DeductionEndTime). Numbers in the plain Capacity fields. + * + * One quota row per package — cadence label (Monthly/Weekly/Daily) for refill + * packs, "Bonus Pack N" for bonus packs (soonest-expiring first). + */ + +const USAGE_URL = "https://copilot.tencent.com/v2/billing/meter/get-user-resource"; + +interface TencentAccount { + PackageName?: string; + SubProductName?: string; + CycleStartTime?: string | number; + CycleEndTime?: string | number; + DeductionEndTime?: string | number; + CycleCapacitySize?: number | string; + CycleCapacitySizePrecise?: string | number; + CycleCapacityUsed?: number | string; + CycleCapacityUsedPrecise?: string | number; + CapacitySize?: number | string; + CapacitySizePrecise?: string | number; + CapacityUsed?: number | string; + CapacityUsedPrecise?: string | number; +} + +function parseResetTime(value: unknown): string | null { + if (!value) return null; + try { + if (value instanceof Date) return value.toISOString(); + if (typeof value === "number") { + const ts = value < 1e12 ? value * 1000 : value; + const d = new Date(ts); + return d.getTime() > 0 ? d.toISOString() : null; + } + if (typeof value === "string") { + if (/^\d+$/.test(value)) { + const n = Number(value); + const d = new Date(n < 1e12 ? n * 1000 : n); + return d.getTime() > 0 ? d.toISOString() : null; + } + const d = new Date(value); + return Number.isNaN(d.getTime()) || d.getTime() <= 0 ? null : d.toISOString(); + } + return null; + } catch { + return null; + } +} + +// Prefer the *Precise string fields (exact), fall back to the numeric ones. +function num(precise: unknown, plain: unknown): number { + const n = Number(precise ?? plain); + return Number.isFinite(n) ? n : 0; +} + +// Label a refill pack by its cycle length (Monthly is the common CodeBuddy case). +function refillCadence(acc: TencentAccount): "Monthly" | "Weekly" | "Daily" { + const start = parseResetTime(acc.CycleStartTime); + const end = parseResetTime(acc.CycleEndTime); + if (start && end) { + const days = (new Date(end).getTime() - new Date(start).getTime()) / 86400000; + if (days <= 1.5) return "Daily"; + if (days <= 10) return "Weekly"; + } + return "Monthly"; +} + +function cycleEndMs(acc: TencentAccount): number { + const r = parseResetTime(acc.CycleEndTime); + return r ? new Date(r).getTime() : Number.POSITIVE_INFINITY; +} + +function deductionEndMs(acc: TencentAccount): number { + const v = acc.DeductionEndTime; + if (typeof v === "number") return (v < 1e12 ? v * 1000 : v); + if (typeof v === "string" && /^\d+$/.test(v)) { + const n = Number(v); + return n < 1e12 ? n * 1000 : n; + } + const r = parseResetTime(v); + return r ? new Date(r).getTime() : Number.POSITIVE_INFINITY; +} + +// Refill packs roll into a new cycle before the resource expires; bonus packs +// end at expiry. >2d gap between cycle end and validity end = refill. +const REFILL_GAP_MS = 2 * 24 * 60 * 60 * 1000; +function isRefill(acc: TencentAccount): boolean { + const ce = cycleEndMs(acc); + const de = deductionEndMs(acc); + return Number.isFinite(ce) && Number.isFinite(de) && de - ce > REFILL_GAP_MS; +} + +interface CodeBuddyUsageResult { + plan?: string; + quotas?: Record< + string, + { + used: number; + total: number; + resetAt: string | null; + unlimited: boolean; + } + >; + message?: string; +} + +export async function getCodeBuddyCnUsage( + accessToken?: string, + apiKey?: string, + _providerSpecificData?: unknown +): Promise { + const token = accessToken || apiKey; + if (!token) { + return { message: "CodeBuddy CN credential not available." }; + } + + try { + const response = await fetch(USAGE_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1", + "X-Product": "SaaS", + "X-IDE-Type": "CLI", + "X-IDE-Name": "CLI", + "x-requested-with": "XMLHttpRequest", + "x-codebuddy-request": "1", + }, + body: "{}", + }); + + if (response.status === 401 || response.status === 403) { + return { message: "CodeBuddy CN credential invalid or expired." }; + } + if (!response.ok) { + return { message: `CodeBuddy CN quota API error (${response.status}).` }; + } + + const json: any = await response.json(); + if (json?.code !== 0) { + return { message: `CodeBuddy CN quota error: ${json?.msg || "unknown"}` }; + } + + const data = json?.data?.Response?.Data || {}; + const accountsRaw: TencentAccount[] = Array.isArray(data.Accounts) ? data.Accounts : []; + if (accountsRaw.length === 0) { + return { message: "CodeBuddy CN connected. No credit package found." }; + } + + const byExpiry = (a: TencentAccount, b: TencentAccount) => cycleEndMs(a) - cycleEndMs(b); + const refills = accountsRaw.filter(isRefill).sort(byExpiry); + const bonuses = accountsRaw.filter((a) => !isRefill(a)).sort(byExpiry); + + const quotas: NonNullable = {}; + const seenRefill: Record = {}; + refills.forEach((acc) => { + const base = refillCadence(acc); + seenRefill[base] = (seenRefill[base] || 0) + 1; + const name = seenRefill[base] > 1 ? `${base} ${seenRefill[base]}` : base; + quotas[name] = { + used: num(acc.CycleCapacityUsedPrecise, acc.CycleCapacityUsed), + total: num(acc.CycleCapacitySizePrecise, acc.CycleCapacitySize), + resetAt: parseResetTime(acc.CycleEndTime), + unlimited: false, + }; + }); + bonuses.forEach((acc, i) => { + quotas[`Bonus Pack ${i + 1}`] = { + used: num(acc.CapacityUsedPrecise, acc.CapacityUsed), + total: num(acc.CapacitySizePrecise, acc.CapacitySize), + resetAt: parseResetTime(acc.CycleEndTime), + unlimited: false, + }; + }); + + const basePkg = refills[0] || accountsRaw[0] || {}; + const plan = basePkg.PackageName || basePkg.SubProductName || "CodeBuddy CN"; + + return { plan, quotas }; + } catch (error: any) { + // Hard Rule #12: no raw err.message in any HTTP/SSE/executor response. Usage + // handler returns a controlled string for the dashboard; do not include the + // raw exception text in case it carries a path/stack snippet. + return { message: "CodeBuddy CN error: failed to fetch quota." }; + } +} + +export default getCodeBuddyCnUsage; diff --git a/scripts/check/check-public-creds.mjs b/scripts/check/check-public-creds.mjs index f73c4a296cda..bf5a1c86af04 100644 --- a/scripts/check/check-public-creds.mjs +++ b/scripts/check/check-public-creds.mjs @@ -88,8 +88,8 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/; // TODO(6A.8): Consider tightening CRED_KEY_RE to exclude function-signature contexts — but // that adds complexity; the FP rate is low (1 file). Frozen by file:line:value key. export const KNOWN_LITERAL_CREDS = new Set([ - "open-sse/services/usage.ts:499:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 582→499 by the OpenCode/Ollama usage extraction) - "open-sse/services/usage.ts:499:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 582→499 by the OpenCode/Ollama usage extraction) + "open-sse/services/usage.ts:504:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved to 504 by OpenCode/Ollama + codebuddy-cn import insertions) + "open-sse/services/usage.ts:504:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved to 504 by OpenCode/Ollama + codebuddy-cn import insertions) ]); /** diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 2ec85a9e3dee..fdc00592cc98 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -196,7 +196,8 @@ export async function GET( provider === "kiro" || provider === "amazon-q" || provider === "kimi-coding" || - provider === "kilocode" + provider === "kilocode" || + provider === "codebuddy-cn" ) { // GitHub, Kiro/Amazon Q, Kimi Coding, and KiloCode don't use PKCE for device code if ((provider === "kiro" || provider === "amazon-q") && startUrl) { @@ -527,7 +528,12 @@ export async function POST( // Poll for token (through proxy if configured) let result; - if (provider === "github" || provider === "kimi-coding" || provider === "kilocode") { + if ( + provider === "github" || + provider === "kimi-coding" || + provider === "kilocode" || + provider === "codebuddy-cn" + ) { // For providers that don't use PKCE (GitHub, Kimi Coding, KiloCode), don't pass codeVerifier result = await runWithProxyContextOrDirect(proxy, () => (pollForToken as any)(provider, deviceCode) diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index da59e883a197..c4dccf97361f 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -122,6 +122,13 @@ const OAUTH_TEST_CONFIG = { checkExpiry: true, refreshable: true, }, + "codebuddy-cn": { + // Upstream test endpoint mirrors "tokenExists: true" from the CodeBuddy port — + // validate auth via token presence + refresh path. Live connectivity is + // verified through real /v2/chat/completions traffic. + checkExpiry: true, + refreshable: true, + }, }; import { CLI_RUNTIME_PROVIDER_MAP } from "./cliRuntimeProviderMap"; diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 4d33ce42296d..d7dbc941c0f3 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -131,6 +131,19 @@ export const QODER_CONFIG = { }, }; +// CodeBuddy CN (Tencent — copilot.tencent.com) OAuth Configuration +// (Custom Device-Auth Flow: POST stateUrl → open authUrl → GET pollUrl?state=). +// No client_id/secret — the upstream CLI ships none. +export const CODEBUDDY_CN_CONFIG = { + baseUrl: "https://copilot.tencent.com", + stateUrl: "https://copilot.tencent.com/v2/plugin/auth/state", + tokenUrl: "https://copilot.tencent.com/v2/plugin/auth/token", + refreshUrl: "https://copilot.tencent.com/v2/plugin/auth/token/refresh", + userAgent: "CLI/2.63.2 CodeBuddy/2.63.2", + platform: "CLI", + pollInterval: 5000, +}; + // Kimi Coding OAuth Configuration (Device Code Flow) export const KIMI_CODING_CONFIG = { clientId: resolvePublicCred("kimi_id", "KIMI_CODING_OAUTH_CLIENT_ID"), @@ -456,4 +469,5 @@ export const PROVIDERS = { WINDSURF: "windsurf", DEVIN_CLI: "devin-cli", TRAE: "trae", + CODEBUDDY_CN: "codebuddy-cn", }; diff --git a/src/lib/oauth/providers/codebuddy-cn.ts b/src/lib/oauth/providers/codebuddy-cn.ts new file mode 100644 index 000000000000..3a4fa9354410 --- /dev/null +++ b/src/lib/oauth/providers/codebuddy-cn.ts @@ -0,0 +1,123 @@ +import { CODEBUDDY_CN_CONFIG } from "../constants/oauth"; + +/** + * CodeBuddy CN (Tencent — copilot.tencent.com) — custom device-auth flow. + * + * 1. POST stateUrl → { code: 0, data: { state, authUrl } } + * 2. Open authUrl in the browser + * 3. GET tokenUrl?state= until { code: 0, data.accessToken } (11217 = pending) + * + * Mirrors the official CodeBuddy CLI's distinguishing detail: poll is GET with + * the state as a query param, NOT POST/body. + */ +type CodeBuddyConfig = typeof CODEBUDDY_CN_CONFIG; + +interface CodeBuddyDeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +} + +interface CodeBuddyTokens { + access_token: string; + refresh_token: string; + token_type: string; + expires_in?: number; +} + +interface CodeBuddyPollResult { + ok: boolean; + data: Record | CodeBuddyTokens; +} + +export const codebuddyCn = { + config: CODEBUDDY_CN_CONFIG, + flowType: "device_code" as const, + + requestDeviceCode: async (config: CodeBuddyConfig): Promise => { + const response = await fetch(config.stateUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": config.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "copilot.tencent.com", + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-Product": "SaaS", + }, + body: JSON.stringify({ platform: config.platform }), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`CodeBuddy state request failed (${response.status})`); + } + + const json = (await response.json()) as { code?: number; data?: any; msg?: string }; + if (json.code !== 0 || !json.data?.state) { + throw new Error(`CodeBuddy state error: ${json.msg || "no state in response"}`); + } + + const state = String(json.data.state); + const authUrl = String(json.data.authUrl || json.data.url || ""); + return { + device_code: state, + user_code: state, + verification_uri: authUrl, + verification_uri_complete: authUrl, + expires_in: 600, + interval: Math.max(1, Math.floor((config.pollInterval || 5000) / 1000)), + }; + }, + + pollToken: async (config: CodeBuddyConfig, deviceCode: string): Promise => { + // GET with state as a query param (not POST/body) — matches the official CLI's + // /v2/plugin/auth/token?state=... endpoint shape. + const response = await fetch( + `${config.tokenUrl}?state=${encodeURIComponent(deviceCode)}`, + { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": config.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "copilot.tencent.com", + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-No-Enterprise-Id": "true", + "X-No-Department-Info": "true", + "X-Product": "SaaS", + }, + } + ); + if (!response.ok) return { ok: false, data: { error: "request_failed" } }; + const data = (await response.json()) as { code?: number; data?: any; msg?: string }; + // code 11217 = pending (RetryFetchToken), code 0 = success + if (data.code === 0 && data.data?.accessToken) { + return { + ok: true, + data: { + access_token: data.data.accessToken, + refresh_token: data.data.refreshToken || "", + token_type: data.data.tokenType || "Bearer", + expires_in: data.data.expiresIn, + }, + }; + } + return { ok: false, data: { code: data.code, msg: data.msg } }; + }, + + mapTokens: (tokens: CodeBuddyTokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in || 86400, + providerSpecificData: {}, + }), +}; + +export default codebuddyCn; diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index 208fbbacdb9d..b98d7b28df74 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -26,6 +26,7 @@ import { trae } from "./trae"; import { kilocode } from "./kilocode"; import { cline } from "./cline"; import { windsurf } from "./windsurf"; +import { codebuddyCn } from "./codebuddy-cn"; export const PROVIDERS = { claude, @@ -47,6 +48,7 @@ export const PROVIDERS = { windsurf, // devin-cli shares the same token format as windsurf (WINDSURF_API_KEY / devin auth login) "devin-cli": windsurf, + "codebuddy-cn": codebuddyCn, }; export default PROVIDERS; diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 5a498f049d87..dacc59701118 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -269,7 +269,8 @@ export default function OAuthModal({ provider === "kiro" || provider === "amazon-q" || provider === "kimi-coding" || - provider === "kilocode" + provider === "kilocode" || + provider === "codebuddy-cn" ) { setIsDeviceCode(true); setStep("waiting"); diff --git a/src/shared/components/lobeProviderIcons.ts b/src/shared/components/lobeProviderIcons.ts index ea657321df26..973bb5caab08 100644 --- a/src/shared/components/lobeProviderIcons.ts +++ b/src/shared/components/lobeProviderIcons.ts @@ -423,6 +423,7 @@ const LOBE_PROVIDER_ALIASES = { tavily: "Tavily", "tavily-search": "Tavily", tencent: "Tencent", + "codebuddy-cn": "Tencent", together: "Together", topaz: "TopazLabs", trae: "Trae", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 1150c2e78d4d..9d45e89bc076 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -125,7 +125,15 @@ export const NOAUTH_PROVIDERS = { }, }; -export const FREE_APIKEY_PROVIDER_IDS = new Set(["qoder", "mimocode", "opencode"]); +export const FREE_APIKEY_PROVIDER_IDS = new Set([ + "qoder", + "mimocode", + "opencode", + // codebuddy-cn is OAuth-primary but the Tencent gateway also accepts a direct + // API key (Authorization: Bearer). Admit it through the same managed-provider + // gate so POST /api/providers accepts the dual-auth shape. + "codebuddy-cn", +]); export function supportsApiKeyOnFreeProvider(providerId: unknown): boolean { return typeof providerId === "string" && FREE_APIKEY_PROVIDER_IDS.has(providerId); @@ -329,6 +337,19 @@ export const OAUTH_PROVIDERS = { "Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai", website: "https://cli.devin.ai", }, + "codebuddy-cn": { + id: "codebuddy-cn", + alias: "cbcn", + name: "CodeBuddy CN", + icon: "smart_toy", + color: "#006EFF", + textIcon: "CB", + website: "https://copilot.tencent.com", + subscriptionRisk: true, + riskNoticeVariant: "oauth", + authHint: + "Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan.", + }, }; // Web / Cookie Providers @@ -3234,6 +3255,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "xiaomi-mimo", "vertex", "vertex-partner", + "codebuddy-cn", ]; // ── Zod validation at module load (Phase 7.2) ── diff --git a/tests/unit/codebuddy-cn-provider.test.ts b/tests/unit/codebuddy-cn-provider.test.ts new file mode 100644 index 000000000000..77759d348e1e --- /dev/null +++ b/tests/unit/codebuddy-cn-provider.test.ts @@ -0,0 +1,262 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + AI_PROVIDERS, + USAGE_SUPPORTED_PROVIDERS, + FREE_APIKEY_PROVIDER_IDS, +} from "../../src/shared/constants/providers.ts"; +import { REGISTRY } from "../../open-sse/config/providerRegistry.ts"; +import { getExecutor } from "../../open-sse/executors/index.ts"; +import { CodeBuddyCnExecutor } from "../../open-sse/executors/codebuddy-cn.ts"; +import { + PROVIDERS as OAUTH_PROVIDER_IDS, + CODEBUDDY_CN_CONFIG, +} from "../../src/lib/oauth/constants/oauth.ts"; +import PROVIDERS_MAP from "../../src/lib/oauth/providers/index.ts"; +import { supportsTokenRefresh } from "../../open-sse/services/tokenRefresh.ts"; + +test("codebuddy-cn is registered as an OAuth provider in the UI catalog", () => { + const p = AI_PROVIDERS["codebuddy-cn"]; + assert.ok(p, "AI_PROVIDERS['codebuddy-cn'] must exist"); + assert.equal(p.id, "codebuddy-cn"); + assert.equal(p.alias, "cbcn"); + assert.equal(p.name, "CodeBuddy CN"); +}); + +test("codebuddy-cn registry entry has expected shape", () => { + const r = REGISTRY["codebuddy-cn"]; + assert.ok(r, "REGISTRY['codebuddy-cn'] must exist"); + assert.equal(r.alias, "cbcn"); + assert.equal(r.executor, "codebuddy-cn"); + assert.equal(r.baseUrl, "https://copilot.tencent.com/v2/chat/completions"); + assert.equal(r.authHeader, "bearer"); + // Headers carry the Tencent CodeBuddy CLI fingerprint + assert.equal(r.headers?.["X-Product"], "SaaS"); + assert.equal(r.headers?.["X-IDE-Type"], "CLI"); + assert.equal(r.headers?.["X-IDE-Name"], "CLI"); + assert.equal(r.headers?.["x-requested-with"], "XMLHttpRequest"); + assert.equal(r.headers?.["x-codebuddy-request"], "1"); + // 15 models from the upstream catalog + const ids = r.models.map((m) => m.id); + for (const expected of [ + "glm-5.2", + "glm-5.1", + "glm-5.0", + "glm-5.0-turbo", + "glm-5v-turbo", + "glm-4.7", + "minimax-m3", + "minimax-m2.7", + "kimi-k2.7", + "kimi-k2.6", + "kimi-k2.5", + "hy3-preview", + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-v3-2-volc", + ]) { + assert.ok(ids.includes(expected), `model ${expected} must be registered`); + } + assert.equal(r.models.length, 15); +}); + +test("codebuddy-cn glm-5.2 carries 1M context and openai-style reasoning that cannot be disabled", () => { + const r = REGISTRY["codebuddy-cn"]; + const m = r.models.find((x) => x.id === "glm-5.2"); + assert.ok(m, "glm-5.2 must exist"); + // contextLength is the OmniRoute analogue of upstream's contextWindow + assert.equal(m.contextLength, 1000000); + assert.equal(m.supportsReasoning, true); +}); + +test("codebuddy-cn vision flag is set on the visual models", () => { + const r = REGISTRY["codebuddy-cn"]; + for (const id of ["glm-5v-turbo", "minimax-m3", "kimi-k2.7", "deepseek-v4-pro"]) { + const m = r.models.find((x) => x.id === id); + assert.ok(m, `${id} must exist`); + assert.equal(m.supportsVision, true, `${id} should have vision`); + } +}); + +test("getExecutor returns the CodeBuddyCnExecutor for 'codebuddy-cn' and the 'cbcn' alias", () => { + const e = getExecutor("codebuddy-cn"); + assert.ok(e instanceof CodeBuddyCnExecutor, "executor must be CodeBuddyCnExecutor"); + const aliasExec = getExecutor("cbcn"); + assert.ok(aliasExec instanceof CodeBuddyCnExecutor, "alias 'cbcn' must resolve to same executor"); +}); + +test("CodeBuddyCnExecutor.transformRequest forces stream:true and adds OpenAI-style reasoning_summary", () => { + const e = new CodeBuddyCnExecutor(); + const out = e.transformRequest( + "glm-5.2", + { model: "glm-5.2", messages: [{ role: "user", content: "hi" }], stream: false }, + false, + {} as unknown as Parameters[3] + ); + assert.ok(out && typeof out === "object" && !Array.isArray(out)); + const body = out as Record; + assert.equal(body.stream, true, "stream must be forced to true"); + assert.equal(body.reasoning_effort, "medium", "default reasoning_effort must be 'medium'"); + assert.equal(body.reasoning_summary, "auto", "reasoning_summary must be 'auto'"); +}); + +test("CodeBuddyCnExecutor preserves explicit reasoning_effort", () => { + const e = new CodeBuddyCnExecutor(); + const out = e.transformRequest( + "glm-5.2", + { model: "glm-5.2", messages: [], reasoning_effort: "high" }, + true, + {} as unknown as Parameters[3] + ); + const body = out as Record; + assert.equal(body.reasoning_effort, "high"); + assert.equal(body.reasoning_summary, "auto"); +}); + +test("CodeBuddyCnExecutor strips reasoning_effort when caller asks for none/off", () => { + const e = new CodeBuddyCnExecutor(); + for (const effort of ["none", "off"]) { + const out = e.transformRequest( + "glm-5.2", + { model: "glm-5.2", messages: [], reasoning_effort: effort }, + true, + {} as unknown as Parameters[3] + ); + const body = out as Record; + assert.equal( + Object.prototype.hasOwnProperty.call(body, "reasoning_effort"), + false, + `reasoning_effort must be omitted for ${effort}` + ); + assert.notEqual(body.reasoning_summary, "auto", `reasoning_summary must not be auto for ${effort}`); + } +}); + +test("codebuddy-cn OAuth provider is wired with device_code flow and GET-poll on state", async () => { + assert.equal(OAUTH_PROVIDER_IDS.CODEBUDDY_CN, "codebuddy-cn"); + const map = (PROVIDERS_MAP as Record); + const cb = map["codebuddy-cn"]; + assert.ok(cb, "PROVIDERS map must include 'codebuddy-cn'"); + assert.equal(cb.flowType, "device_code"); + assert.equal(typeof cb.requestDeviceCode, "function"); + assert.equal(typeof cb.pollToken, "function"); + + // Drive pollToken with a stubbed fetch and assert it hits the token URL via GET + // with the state as a query param (upstream's distinguishing detail). + const origFetch = globalThis.fetch; + const calls: { url: string; init?: RequestInit }[] = []; + globalThis.fetch = async (url: any, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return new Response( + JSON.stringify({ + code: 0, + data: { + accessToken: "AT", + refreshToken: "RT", + tokenType: "Bearer", + expiresIn: 3600, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) as unknown as Response; + }; + try { + const result = await cb.pollToken(CODEBUDDY_CN_CONFIG, "STATE-123"); + assert.ok(result?.ok, "successful poll should be ok:true"); + assert.equal(result?.data?.access_token, "AT"); + const got = calls.at(-1); + assert.ok(got, "fetch must have been called"); + assert.equal(got.init?.method, "GET", "poll method must be GET"); + assert.match(got.url, /\?state=STATE-123$/, "state must appear as ?state= query param"); + } finally { + globalThis.fetch = origFetch; + } +}); + +test("codebuddy-cn token refresh handler is wired in tokenRefresh.ts", () => { + assert.equal(supportsTokenRefresh("codebuddy-cn"), true); +}); + +test("codebuddy-cn is in USAGE_SUPPORTED_PROVIDERS and quota handler parses Tencent accounts", async () => { + assert.ok(USAGE_SUPPORTED_PROVIDERS.includes("codebuddy-cn")); + const { getCodeBuddyCnUsage } = await import( + "../../open-sse/services/usage/codebuddy-cn.ts" + ); + + const origFetch = globalThis.fetch; + // Compose a mixed payload: one refill (CycleEndTime << DeductionEndTime) and + // two bonus packs (CycleEndTime == DeductionEndTime), out of expiry order. + const cycleEnd = "2026-08-01T00:00:00Z"; + const deductionEnd = 32503680000; // very far future (seconds) + const bonus1End = "2026-07-01T00:00:00Z"; + const bonus2End = "2026-06-25T00:00:00Z"; + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ + code: 0, + data: { + Response: { + Data: { + Accounts: [ + { + PackageName: "Refill Pack", + CycleStartTime: "2026-07-01T00:00:00Z", + CycleEndTime: cycleEnd, + DeductionEndTime: deductionEnd, + CycleCapacitySize: 500, + CycleCapacityUsed: 12.34, + }, + { + PackageName: "Bonus A", + CycleStartTime: "2026-05-01T00:00:00Z", + CycleEndTime: bonus1End, + DeductionEndTime: new Date(bonus1End).getTime() / 1000, + CapacitySize: 50, + CapacityUsed: 1, + }, + { + PackageName: "Bonus B", + CycleStartTime: "2026-05-01T00:00:00Z", + CycleEndTime: bonus2End, + DeductionEndTime: new Date(bonus2End).getTime() / 1000, + CapacitySize: 25, + CapacityUsed: 5, + }, + ], + }, + }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) as unknown as Response; + }; + + try { + const result = await getCodeBuddyCnUsage("ACCESS_TOKEN", undefined, undefined); + assert.ok(result && typeof result === "object"); + const r = result as Record; + assert.equal(r.plan, "Refill Pack"); + assert.ok(r.quotas); + // Refill (Monthly) uses CycleCapacity*; Bonus packs use plain Capacity*; soonest-expiring first. + assert.ok(r.quotas.Monthly, "Monthly refill quota must be present"); + assert.equal(r.quotas.Monthly.total, 500); + assert.equal(r.quotas.Monthly.used, 12.34); + assert.ok(r.quotas["Bonus Pack 1"], "first bonus must be present"); + assert.ok(r.quotas["Bonus Pack 2"], "second bonus must be present"); + // Soonest-expiring first: Bonus B (Jun 25) before Bonus A (Jul 1). + assert.equal(r.quotas["Bonus Pack 1"].total, 25, "Bonus Pack 1 should be the soonest (B)"); + assert.equal(r.quotas["Bonus Pack 2"].total, 50, "Bonus Pack 2 should be the later (A)"); + } finally { + globalThis.fetch = origFetch; + } +}); + +test("codebuddy-cn is treated as a managed dual-auth provider (oauth + apikey accepted by POST /api/providers)", async () => { + // The provider creation gate trusts FREE_APIKEY_PROVIDER_IDS to admit + // OAuth-category providers that also accept a direct API key (like qoder). + assert.ok( + FREE_APIKEY_PROVIDER_IDS.has("codebuddy-cn"), + "codebuddy-cn must be admitted by the dual-auth gate" + ); +}); From 122f6d3a195f17d44c1b52fb1eadefcc36cbee76 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:10:11 -0300 Subject: [PATCH 041/120] =?UTF-8?q?feat(combo):=20Fusion=20strategy=20?= =?UTF-8?q?=E2=80=94=20parallel=20panel=20+=20judge=20synthesis=20(16th=20?= =?UTF-8?q?strategy)=20(#4652)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — Fusion combo strategy (16th, port 87e5c1c6); combo.ts baseline reconciled; release-green --- config/quality/file-size-baseline.json | 2 +- open-sse/services/combo.ts | 35 ++- open-sse/services/fusion.ts | 327 ++++++++++++++++++++++ src/i18n/messages/en.json | 2 + src/shared/constants/routingStrategies.ts | 8 + tests/unit/combo-fusion-strategy.test.ts | 209 ++++++++++++++ 6 files changed, 581 insertions(+), 2 deletions(-) create mode 100644 open-sse/services/fusion.ts create mode 100644 tests/unit/combo-fusion-strategy.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 8229ee41ca99..1fc9ac5b98d1 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -140,7 +140,7 @@ "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, "_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.", - "open-sse/services/combo.ts": 3036, + "open-sse/services/combo.ts": 3069, "open-sse/services/compression/strategySelector.ts": 848, "open-sse/services/rateLimitManager.ts": 1035, "open-sse/services/tokenRefresh.ts": 2070, diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 69d293280421..46ec4f62ccad 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -2,7 +2,7 @@ * Shared combo (model combo) handling with fallback support * Supports: priority, weighted, round-robin, random, least-used, cost-optimized, * reset-aware, reset-window, strict-random, auto, fill-first, p2c, lkgp, - * context-optimized, and context-relay strategies + * context-optimized, context-relay, and fusion strategies */ import { @@ -118,6 +118,7 @@ import { recordStickyWeightedSuccess, } from "./combo/rrState.ts"; import { validateResponseQuality, toRetryAfterDisplayValue } from "./combo/validateQuality.ts"; +import { handleFusionChat, type FusionTuning } from "./fusion.ts"; import { TRANSIENT_FOR_SEMAPHORE, MAX_FALLBACK_WAIT_MS, @@ -737,6 +738,38 @@ export async function handleComboChat({ return handleSingleModelWithTimeout(body, pinnedModel); } + // Fusion strategy: parallel panel + judge synthesis. Handled in a separate module + // because it neither iterates targets in order nor needs the failover/retry/credential + // gate machinery that follows — it fans out, then synthesizes once. + if (strategy === "fusion") { + const fusionModels = (combo.models || []) + .map((m) => { + if (typeof m === "string") return m; + if (m && typeof m === "object") { + const obj = m as Record; + if (typeof obj.model === "string") return obj.model; + } + return null; + }) + .filter((m): m is string => Boolean(m)); + const cfg = config as Record; + const judgeModel = + typeof cfg.judgeModel === "string" ? cfg.judgeModel : undefined; + const tuning = + cfg.fusionTuning && typeof cfg.fusionTuning === "object" + ? (cfg.fusionTuning as FusionTuning) + : undefined; + return handleFusionChat({ + body, + models: fusionModels, + handleSingleModel: handleSingleModelWithTimeout, + log, + comboName: combo.name, + judgeModel, + tuning, + }); + } + const nestingContext = nesting || { depth: 0, maxDepth: clampComboDepth(config.maxComboDepth), diff --git a/open-sse/services/fusion.ts b/open-sse/services/fusion.ts new file mode 100644 index 000000000000..2ebde14a3ae7 --- /dev/null +++ b/open-sse/services/fusion.ts @@ -0,0 +1,327 @@ +/** + * Fusion combo strategy — parallel panel + judge synthesis. + * + * A fusion combo fans the prompt out to every panel model in parallel, then a + * configurable judge model synthesizes one final answer from all panel responses. + * + * - quorum-grace collection caps the straggler penalty (the slowest model + * otherwise dominates wall time); + * - anonymized sources prevent judge brand-bias ("Source N" rather than model name); + * - degrades to a direct answer on a single survivor, 503 on total failure. + * + * Per OpenRouter's Fusion design, the judge does NOT merge — it analyzes + * (consensus / contradictions / partial coverage / unique insights / blind spots) + * then writes one answer grounded in that analysis. Most of fusion's quality lift + * comes from this synthesis step. + * + * Ported from upstream decolua/9router (Daniil Schovkunov), adapted JS → TS and + * wired through OmniRoute's existing combo schema (combo.config.judgeModel / + * combo.config.fusionTuning). + */ +import { errorResponse, sanitizeErrorMessage } from "../utils/error.ts"; +import { extractTextContent } from "../translator/helpers/geminiHelper.ts"; +import type { ComboLogger, HandleSingleModel } from "./combo/types.ts"; + +// Fusion tuning. Overridable per-combo via combo.config.fusionTuning. +export const FUSION_DEFAULTS = { + minPanel: 2, // answers needed before stragglers get a grace window + stragglerGraceMs: 8000, // wait this long for laggards once quorum is reached + panelHardTimeoutMs: 90000, // absolute cap so one hung model can't stall forever +} as const; + +export type FusionTuning = { + minPanel?: number; + stragglerGraceMs?: number; + panelHardTimeoutMs?: number; +}; + +type Body = Record; + +/** + * Extract assistant text from a non-stream completion across formats + * (OpenAI chat, Claude messages, Gemini, OpenAI Responses). Returns "" if none. + * Panel responses are already translated to the client format by chatCore, so the + * leaf content → string step reuses the translator's own extractTextContent. + */ +export function extractPanelText(json: unknown): string { + if (!json || typeof json !== "object") return ""; + const j = json as Record; + + // OpenAI chat completion + const choices = j.choices as Array> | undefined; + const choice = choices?.[0]; + if (choice) { + const msg = (choice.message ?? choice.delta ?? {}) as Record; + const t = extractTextContent(msg.content); + if (t.trim()) return t; + if (typeof choice.text === "string" && choice.text.trim()) return choice.text; + } + + // Claude messages (text blocks share OpenAI's {type:"text"} shape) + const claudeText = extractTextContent(j.content); + if (claudeText.trim()) return claudeText; + + // Gemini (parts carry .text without a type discriminator) + const candidates = j.candidates as Array> | undefined; + const parts = (candidates?.[0]?.content as Record | undefined)?.parts as + | Array<{ text?: unknown }> + | undefined; + if (Array.isArray(parts)) { + const t = parts.map((p) => (typeof p?.text === "string" ? p.text : "")).join(""); + if (t.trim()) return t; + } + + // OpenAI Responses API + const output = j.output as Array> | undefined; + if (Array.isArray(output)) { + const t = output + .flatMap((o) => + Array.isArray(o.content) + ? (o.content as Array<{ text?: unknown }>).map((c) => + typeof c?.text === "string" ? c.text : "" + ) + : [] + ) + .join(""); + if (t.trim()) return t; + } + + return ""; +} + +/** + * Append a synthesized user turn to whichever message array the request format uses. + * Preserves the original conversation + system prompt so the judge has full context. + */ +export function appendUserTurn(body: Body, text: string): Body { + const next: Body = { ...body }; + if (Array.isArray(body.messages)) { + next.messages = [...(body.messages as unknown[]), { role: "user", content: text }]; + } else if (Array.isArray(body.input)) { + next.input = [...(body.input as unknown[]), { role: "user", content: text }]; + } else if (Array.isArray(body.contents)) { + next.contents = [ + ...(body.contents as unknown[]), + { role: "user", parts: [{ text }] }, + ]; + } else { + next.messages = [{ role: "user", content: text }]; + } + return next; +} + +/** + * Build the judge directive. Sources are anonymized ("Source N") so the judge + * weighs substance, not the reputation of a model brand. + */ +export function buildJudgePrompt(answers: Array<{ text: string }>): string { + const panel = answers.map((a, i) => `[Source ${i + 1}]\n${a.text}`).join("\n\n"); + + return [ + `You are the JUDGE in a model-fusion panel. ${answers.length} expert models independently answered the user's most recent request. Their responses are below, anonymized by source.`, + "", + "Do NOT mention that multiple models were used, and do NOT refer to the sources. Produce ONE authoritative final answer addressed directly to the user.", + "", + "First, internally analyze the panel along these dimensions: consensus (points most sources agree on — treat as higher-confidence), contradictions (where they disagree — resolve with your own judgment), partial coverage, unique insights only one source surfaced, and blind spots every source missed. Then write the best possible final answer grounded in that analysis — more complete and correct than any single response, with no filler.", + "", + "=== PANEL RESPONSES ===", + panel, + "=== END PANEL RESPONSES ===", + "", + "Now write the final answer to the user's original request.", + ].join("\n"); +} + +type Sentinel = { __timeout?: true; __error?: unknown }; + +// Resolve a Response (or sentinel) within ms; the loser keeps running but is ignored. +function withTimeout( + promise: Promise, + ms: number +): Promise { + return new Promise((resolve) => { + const t = setTimeout(() => resolve({ __timeout: true }), ms); + Promise.resolve(promise) + .then((v) => { + clearTimeout(t); + resolve(v); + }) + .catch((e) => { + clearTimeout(t); + resolve({ __error: e }); + }); + }); +} + +/** + * Collect panel responses with quorum-grace: as soon as `minPanel` calls succeed, + * start a short grace timer for the rest, then proceed with whatever arrived. This + * caps the straggler penalty while still preferring a full panel when everyone is + * fast. Bounded by a hard timeout. + * + * Returns a sparse array aligned to `calls` (undefined = not yet / dropped). + */ +export function collectPanel( + calls: Array>, + cfg: { minPanel: number; stragglerGraceMs: number; panelHardTimeoutMs: number } +): Promise> { + return new Promise((resolve) => { + const out: Array = new Array(calls.length); + let settled = 0; + let ok = 0; + let finished = false; + let graceTimer: ReturnType | null = null; + const finish = () => { + if (finished) return; + finished = true; + clearTimeout(hardTimer); + if (graceTimer) clearTimeout(graceTimer); + resolve(out); + }; + const hardTimer = setTimeout(finish, cfg.panelHardTimeoutMs); + calls.forEach((p, i) => { + Promise.resolve(p) + .then((v) => { + out[i] = v; + }) + .catch((e) => { + out[i] = { __error: e }; + }) + .finally(() => { + settled++; + const slot = out[i] as Response | undefined; + if (slot && (slot as Response).ok) ok++; + if (settled === calls.length) return finish(); + if (ok >= cfg.minPanel && !graceTimer) { + graceTimer = setTimeout(finish, cfg.stragglerGraceMs); + } + }); + }); + }); +} + +export type HandleFusionChatOptions = { + body: Body; + models: string[]; + handleSingleModel: HandleSingleModel; + log: ComboLogger; + comboName?: string; + judgeModel?: string | null; + tuning?: FusionTuning | null; +}; + +/** + * Handle a fusion combo: fan the prompt out to every panel model in parallel, + * then a judge model synthesizes one final answer from all panel responses. + * + * Panel calls are forced non-streaming with tools stripped (the judge needs + * complete prose to synthesize). The judge call keeps the client's original + * stream flag + tools, so streaming and downstream tool use still work. + * + * Speed: quorum-grace collection caps the straggler penalty. Quality: the judge + * runs the consensus/contradiction/blind-spot analysis before writing. + * + * Degrades gracefully: 0 panel answers → 503, exactly 1 → return it directly. + */ +export async function handleFusionChat({ + body, + models, + handleSingleModel, + log, + comboName, + judgeModel, + tuning, +}: HandleFusionChatOptions): Promise { + const panel = Array.isArray(models) ? models.filter(Boolean) : []; + if (panel.length === 0) { + return errorResponse(400, "Fusion combo has no models"); + } + + // A single-model fusion has nothing to fuse — just answer directly. + if (panel.length === 1) { + return handleSingleModel(body, panel[0]); + } + + const cfg = { + minPanel: tuning?.minPanel ?? FUSION_DEFAULTS.minPanel, + stragglerGraceMs: tuning?.stragglerGraceMs ?? FUSION_DEFAULTS.stragglerGraceMs, + panelHardTimeoutMs: tuning?.panelHardTimeoutMs ?? FUSION_DEFAULTS.panelHardTimeoutMs, + }; + const minPanel = Math.min(Math.max(2, cfg.minPanel), panel.length); + const judge = judgeModel && judgeModel.trim() ? judgeModel.trim() : panel[0]; + log.info( + "FUSION", + `Combo "${comboName ?? ""}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}` + ); + + // 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose). + const { tools: _tools, tool_choice: _tc, ...rest } = body; + void _tools; + void _tc; + const panelBody: Body = { ...rest, stream: false }; + const t0 = Date.now(); + const calls = panel.map((m) => + withTimeout(handleSingleModel(panelBody, m), cfg.panelHardTimeoutMs) + ); + const settled = await collectPanel(calls, { ...cfg, minPanel }); + log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`); + + // 2. Collect successful answers. + const answers: Array<{ model: string; text: string }> = []; + for (let i = 0; i < settled.length; i++) { + const res = settled[i]; + const model = panel[i]; + if (!res) { + log.warn("FUSION", `Panel ${model} dropped (straggler/timeout)`); + continue; + } + const sentinel = res as Sentinel; + if (sentinel.__timeout) { + log.warn("FUSION", `Panel ${model} timed out`); + continue; + } + if (sentinel.__error) { + log.warn("FUSION", `Panel ${model} threw`, { + error: sanitizeErrorMessage(sentinel.__error as Error), + }); + continue; + } + const resp = res as Response; + if (!resp.ok) { + log.warn("FUSION", `Panel ${model} failed`, { status: resp.status }); + continue; + } + try { + const json = await resp.clone().json(); + const text = extractPanelText(json); + if (text) { + answers.push({ model, text }); + log.info("FUSION", `Panel ${model} ok (${text.length} chars)`); + } else { + log.warn("FUSION", `Panel ${model} returned empty content`); + } + } catch (e) { + log.warn("FUSION", `Panel ${model} unparseable`, { + error: sanitizeErrorMessage(e as Error), + }); + } + } + + // 3. Degrade gracefully when the panel is too thin to fuse. + if (answers.length === 0) { + log.warn("FUSION", "All panel models failed"); + return errorResponse(503, "All fusion panel models failed"); + } + if (answers.length === 1) { + log.info( + "FUSION", + `Only ${answers[0].model} succeeded — answering directly (no fusion)` + ); + return handleSingleModel(body, answers[0].model); + } + + // 4. Judge analyzes + writes one final answer (streams to client if requested). + const judgeBody = appendUserTurn(body, buildJudgePrompt(answers)); + log.info("FUSION", `Judging ${answers.length} answers with ${judge}`); + return handleSingleModel(judgeBody, judge); +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index beb013043f32..4d14c2acd655 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2384,6 +2384,8 @@ "resetAwareDesc": "Balances remaining quota against 5h and weekly resets, then round-robins similar scores", "strictRandom": "Strict Random", "strictRandomDesc": "Shuffle deck — uses each model once before reshuffling", + "fusion": "Fusion", + "fusionDesc": "Fans the prompt to every panel model in parallel, then a judge model synthesizes one final answer", "models": "Models", "autoBalance": "Auto-balance", "advancedSettings": "Advanced Settings", diff --git a/src/shared/constants/routingStrategies.ts b/src/shared/constants/routingStrategies.ts index 18e35e3d4cdf..1e09f350ac61 100644 --- a/src/shared/constants/routingStrategies.ts +++ b/src/shared/constants/routingStrategies.ts @@ -14,6 +14,7 @@ export const ROUTING_STRATEGY_VALUES = [ "auto", "lkgp", "context-optimized", + "fusion", ] as const; export type RoutingStrategyValue = (typeof ROUTING_STRATEGY_VALUES)[number]; @@ -170,6 +171,13 @@ export const ROUTING_STRATEGIES: RoutingStrategyOption[] = [ settingsDescKey: "contextOptDesc", icon: "text_snippet", }, + { + value: "fusion", + labelKey: "fusion", + combosDescKey: "fusionDesc", + settingsDescKey: "fusionDesc", + icon: "hub", + }, ]; export const SETTINGS_FALLBACK_STRATEGY_VALUES = ACCOUNT_FALLBACK_STRATEGY_VALUES; diff --git a/tests/unit/combo-fusion-strategy.test.ts b/tests/unit/combo-fusion-strategy.test.ts new file mode 100644 index 000000000000..775baef70ff9 --- /dev/null +++ b/tests/unit/combo-fusion-strategy.test.ts @@ -0,0 +1,209 @@ +/** + * Fusion combo strategy — parallel panel + judge synthesis. + * + * Ported from upstream decolua/9router (Daniil Schovkunov). Adds Fusion as the 16th + * combo strategy: fan the prompt out to every panel model in parallel, then a judge + * model synthesizes one final answer from all panel responses. + */ +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-combo-fusion-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-fusion-test-secret"; + +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); + +const noop = () => {}; +const log = { info: noop, warn: noop, debug: noop, error: noop }; + +type Body = Record; + +// Minimal OpenAI-chat Response-shaped object compatible with the engine's .ok + .clone().json() surface. +function okResponse(content: string, { delayMs = 0 } = {}): Response | Promise { + const body = JSON.stringify({ choices: [{ message: { role: "assistant", content } }] }); + const make = () => + new Response(body, { status: 200, headers: { "Content-Type": "application/json" } }); + return delayMs > 0 ? new Promise((r) => setTimeout(() => r(make()), delayMs)) : make(); +} + +function errResponse(status = 500): Response { + return new Response(JSON.stringify({ error: { message: "boom" } }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function fusionCombo(models: string[], extra: Record = {}) { + return { + name: "test-fusion-combo", + strategy: "fusion", + models: models.map((m) => ({ model: m })), + config: extra, + }; +} + +test("fusion: single-model panel answers directly (nothing to fuse)", async () => { + const calls: string[] = []; + const handleSingleModel = async (_b: Body, m: string) => { + calls.push(m); + return okResponse("solo"); + }; + const res = await handleComboChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + combo: fusionCombo(["p/only"]), + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + assert.equal(calls.length, 1); + assert.equal(calls[0], "p/only"); + assert.equal(res.status, 200); +}); + +test("fusion: fans out to the panel then routes a synthesis turn to the judge", async () => { + const seen: string[] = []; + const seenBodies: Body[] = []; + const handleSingleModel = async (b: Body, m: string) => { + seen.push(m); + seenBodies.push(b); + if (m === "p/judge") return okResponse("FINAL"); + return okResponse(`ans-${m}`); + }; + + const res = await handleComboChat({ + body: { + messages: [{ role: "user", content: "Q" }], + stream: true, + tools: [{ name: "x" }], + }, + combo: fusionCombo(["p/a", "p/b", "p/c"], { judgeModel: "p/judge" }), + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + + // 3 panel calls + 1 judge call. + assert.equal(seen.length, 4); + assert.deepEqual(seen.slice(0, 3).sort(), ["p/a", "p/b", "p/c"]); + assert.equal(seen[3], "p/judge"); + + // Panel calls are non-streaming with tools stripped. + for (let i = 0; i < 3; i++) { + const b = seenBodies[i]; + assert.equal(b.stream, false, "panel call should be non-streaming"); + assert.equal(b.tools, undefined, "panel call should have tools stripped"); + } + + // Judge call carries every panel answer + keeps the client's stream flag. + const judgeBody = seenBodies[3]; + const judgeMsgs = judgeBody.messages as Array<{ role: string; content: string }>; + const judgeText = judgeMsgs[judgeMsgs.length - 1].content; + assert.match(judgeText, /ans-p\/a/); + assert.match(judgeText, /ans-p\/b/); + assert.match(judgeText, /ans-p\/c/); + assert.match(judgeText, /Source 1/); + assert.equal(judgeBody.stream, true); + + assert.equal(res.status, 200); +}); + +test("fusion: defaults the judge to the first panel model when none is set", async () => { + const seen: string[] = []; + const handleSingleModel = async (_b: Body, m: string) => { + seen.push(m); + return okResponse(`ans-${m}`); + }; + await handleComboChat({ + body: { messages: [{ role: "user", content: "Q" }] }, + combo: fusionCombo(["p/first", "p/second"]), + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + // Last call is the judge; defaults to panel[0]. + assert.equal(seen[seen.length - 1], "p/first"); +}); + +test("fusion: proceeds on quorum without waiting for a straggler (grace window)", async () => { + const handleSingleModel = async (_b: Body, m: string) => { + if (m === "p/slow") return okResponse("slow", { delayMs: 5000 }); + if (m === "p/judge") return okResponse("FINAL"); + return okResponse(`fast-${m}`); + }; + + const t0 = Date.now(); + const seenBodies: Body[] = []; + const wrapped = async (b: Body, m: string) => { + seenBodies.push(b); + return handleSingleModel(b, m); + }; + await handleComboChat({ + body: { messages: [{ role: "user", content: "Q" }] }, + combo: fusionCombo(["p/x", "p/y", "p/slow"], { + judgeModel: "p/judge", + fusionTuning: { minPanel: 2, stragglerGraceMs: 50, panelHardTimeoutMs: 10000 }, + }), + handleSingleModel: wrapped, + log, + settings: {}, + allCombos: [], + }); + const elapsed = Date.now() - t0; + + // Two fast answers reach quorum; grace is 50ms, so we never wait ~5s for p/slow. + assert.ok(elapsed < 2000, `should not wait for straggler (took ${elapsed}ms)`); + + const judgeBody = seenBodies[seenBodies.length - 1]; + const judgeMsgs = judgeBody.messages as Array<{ role: string; content: string }>; + const judgeText = judgeMsgs[judgeMsgs.length - 1].content; + assert.match(judgeText, /fast-p\/x/); + assert.match(judgeText, /fast-p\/y/); + assert.ok(!/slow/.test(judgeText), "straggler answer should not appear in the judge prompt"); +}); + +test("fusion: returns the lone survivor directly when only one panel model succeeds", async () => { + const seen: string[] = []; + const handleSingleModel = async (_b: Body, m: string) => { + seen.push(m); + if (m === "p/ok") return okResponse("lone"); + return errResponse(500); + }; + await handleComboChat({ + body: { messages: [{ role: "user", content: "Q" }] }, + combo: fusionCombo(["p/ok", "p/bad"], { + judgeModel: "p/judge", + fusionTuning: { minPanel: 2, stragglerGraceMs: 50, panelHardTimeoutMs: 5000 }, + }), + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + // No judge call — single answer means there is nothing to fuse. + assert.ok( + !seen.includes("p/judge"), + "judge should not be invoked when only one panel model survives" + ); +}); + +test("fusion: returns 503 when the whole panel fails", async () => { + const handleSingleModel = async () => errResponse(500); + const res = await handleComboChat({ + body: { messages: [{ role: "user", content: "Q" }] }, + combo: fusionCombo(["p/a", "p/b"], { + fusionTuning: { minPanel: 2, stragglerGraceMs: 50, panelHardTimeoutMs: 5000 }, + }), + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + assert.equal(res.status, 503); +}); From 396a9a72cd4feceb523b8e80d238c5ef20c1eeed Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:12:12 -0300 Subject: [PATCH 042/120] feat(proxy-pool): Deno Deploy relays + group action buttons (#4643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — Deno Deploy relays (port #1437); proxies.ts baseline reconciled + env docs restored; release-green --- .env.example | 14 + config/quality/file-size-baseline.json | 2 +- docs/reference/ENVIRONMENT.md | 3 + open-sse/utils/proxyDispatcher.ts | 21 +- open-sse/utils/proxyFetch.ts | 22 +- .../components/proxy/DenoRelayModal.tsx | 154 +++++++++ .../components/proxy/ProxyPoolTab.tsx | 93 +++++- .../api/settings/proxy/deno-deploy/route.ts | 297 ++++++++++++++++++ src/i18n/messages/en.json | 15 + src/lib/db/proxies.ts | 21 +- src/shared/validation/freeProxySchemas.ts | 28 ++ .../unit/proxy-pool-deno-deploy-relay.test.ts | 100 ++++++ 12 files changed, 738 insertions(+), 32 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx create mode 100644 src/app/api/settings/proxy/deno-deploy/route.ts create mode 100644 tests/unit/proxy-pool-deno-deploy-relay.test.ts diff --git a/.env.example b/.env.example index 26d6ee99e76f..68cebdba874b 100644 --- a/.env.example +++ b/.env.example @@ -1205,6 +1205,20 @@ APP_LOG_TO_FILE=true # Used by: open-sse/executors/cloudflare-ai.ts # CLOUDFLARE_ACCOUNT_ID= +# ── Deno Deploy proxy relay (#4643 / 9router#1437) ── +# Override the Deno Deploy REST API base used by the proxy-pool relay deployer. +# Default: https://api.deno.com/v2 (omit unless mocking). +# Used by: src/app/api/settings/proxy/deno-deploy/route.ts +# DENO_DEPLOY_API_BASE=https://api.deno.com/v2 + +# Default Deno Deploy app name suggested in the "Deploy Relay" modal. +# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx +# NEXT_PUBLIC_DENO_RELAY_DEFAULT_PROJECT=omniroute-deno-relay + +# Set to "false" to hide the Deno Deploy relay option from the Proxy Pool tab. +# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx +# NEXT_PUBLIC_DENO_RELAY_ENABLED=true + # ── Cloudflare Tunnel (cloudflared) ── # Custom path to cloudflared binary for tunnel management. # Used by: src/lib/cloudflaredTunnel.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 1fc9ac5b98d1..025383328473 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -197,7 +197,7 @@ "src/lib/db/migrationRunner.ts": 1125, "src/lib/db/models.ts": 1259, "src/lib/db/providers.ts": 1063, - "src/lib/db/proxies.ts": 1048, + "src/lib/db/proxies.ts": 1057, "src/lib/db/settings.ts": 1149, "src/lib/db/usageAnalytics.ts": 873, "src/lib/evals/evalRunner.ts": 961, diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index ad9a789b522d..6aa926c11d9f 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -746,6 +746,9 @@ Automatic model pricing data synchronization from external sources. | `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. | | `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. | diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index df120d11a6fc..a557e058ea04 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -9,6 +9,15 @@ const DISPATCHER_CACHE_KEY = Symbol.for("omniroute.proxyDispatcher.cache"); const DEFAULT_DISPATCHER_KEY = Symbol.for("omniroute.proxyDispatcher.default"); const RETRY_DISPATCHER_KEY = Symbol.for("omniroute.proxyDispatcher.retry"); const SUPPORTED_PROTOCOLS = new Set(["http:", "https:", "socks5:"]); +// Edge-relay proxy types. These do NOT go through an HTTP/SOCKS dispatcher — +// the caller wraps the upstream URL with buildRelayHeaders() and fetches the +// relay endpoint directly. Keep this set as the single source of truth so +// every dispatch decision stays in sync when a new relay backend lands. +export const RELAY_TYPES: ReadonlySet = new Set(["vercel", "deno"]); + +export function isRelayType(type: string | undefined | null): boolean { + return typeof type === "string" && RELAY_TYPES.has(type); +} const DEFAULT_PROXY_DISPATCHER_CONNECTIONS = 32; const MAX_PROXY_DISPATCHER_CONNECTIONS = 256; @@ -317,6 +326,11 @@ export function buildVercelRelayHeaders( }; } +// Vercel + Deno Deploy share the same x-relay-{target,path,auth} envelope. +// Use this alias when the call is intentionally backend-agnostic; the named +// vercel-specific export above stays for direct callers that already use it. +export const buildRelayHeaders = buildVercelRelayHeaders; + export function proxyConfigToUrl( proxyConfig: unknown, { allowSocks5 = isSocks5ProxyEnabled() } = {} @@ -337,9 +351,10 @@ export function proxyConfigToUrl( if (!config.host) return null; const type = String(config.type || "http").toLowerCase(); - // Vercel Relay entries carry the relay URL in `host` — no dispatcher needed; - // callers should use buildVercelRelayHeaders() and fetch directly. - if (type === "vercel") { + // Edge-relay entries (vercel / deno) carry the relay URL in `host` — no + // dispatcher needed; callers should use buildRelayHeaders() and fetch + // the relay endpoint directly. + if (RELAY_TYPES.has(type)) { return config.host ? `https://${config.host}` : null; } diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 87d925e745e6..680377009d83 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -7,6 +7,7 @@ import { createProxyDispatcher, getDefaultDispatcher, getRetryDispatcher, + isRelayType, normalizeProxyUrl, proxyConfigToUrl, proxyUrlForLogs, @@ -277,9 +278,10 @@ export async function runWithProxyContext( // T14: Proxy Fast-Fail // Perform a short TCP reachability check before issuing upstream requests. - // Skip for vercel-relay type: proxyConfigToUrl returns "https://" which is the - // relay endpoint itself, not a proxy — the actual routing is handled via relay headers. - const isVercelRelay = (effectiveProxyConfig as { type?: string })?.type === "vercel"; + // Skip for edge-relay types (vercel / deno): proxyConfigToUrl returns + // "https://" which is the relay endpoint itself, not an HTTP proxy — + // the actual routing is handled via x-relay-* headers below. + const isVercelRelay = isRelayType((effectiveProxyConfig as { type?: string })?.type); if (resolvedProxyUrl && !isVercelRelay) { const reachable = await isProxyReachable(resolvedProxyUrl); if (!reachable) { @@ -506,20 +508,22 @@ async function patchedFetch( throw lastDispatcherError; } - // Vercel Relay: instead of routing through an HTTP proxy dispatcher, we send - // relay headers to the Vercel edge function which forwards the request upstream. + // Edge relay (vercel / deno): instead of routing through an HTTP proxy + // dispatcher, we send x-relay-* headers to the edge function which forwards + // the request upstream. Both backends share the same envelope shape. const contextProxy = proxyContext.getStore(); if ( contextProxy && typeof contextProxy === "object" && - (contextProxy as { type?: string }).type === "vercel" + isRelayType((contextProxy as { type?: string }).type) ) { - const vc = contextProxy as { host?: string; relayAuth?: string }; + const vc = contextProxy as { type?: string; host?: string; relayAuth?: string }; if (!vc.relayAuth) { // Generic message without internal labels — this throw can bubble up to // catch blocks that put error.message in response bodies (combo per-model // timeout, executor catch-all). Don't leak "[ProxyFetch]" diagnostics. - throw new Error("Vercel relay configuration error: missing relayAuth"); + const label = vc.type === "vercel" ? "Vercel relay" : `${vc.type || "Edge"} relay`; + throw new Error(`${label} configuration error: missing relayAuth`); } const targetUrl = getTargetUrl(input); const relayHeaders = buildVercelRelayHeaders(targetUrl, vc.relayAuth); @@ -529,7 +533,7 @@ async function patchedFetch( // to relay routing logs (the rest of this module already follows that rule). const hostForLogs = proxyUrlForLogs(vc.host ? `https://${vc.host}` : ""); if (process.env.OMNIROUTE_PROXY_FETCH_DEBUG === "true") { - console.debug(`[ProxyFetch] Routing via Vercel relay: ${hostForLogs}`); + console.debug(`[ProxyFetch] Routing via ${vc.type || "edge"} relay: ${hostForLogs}`); } return await originalFetch(`https://${vc.host}`, { ...options, diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx new file mode 100644 index 000000000000..a7f8b39a30e2 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx @@ -0,0 +1,154 @@ +"use client"; +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/shared/components"; + +interface DenoRelayModalProps { + isOpen: boolean; + onClose: () => void; + onDeployed: (poolProxyId: string, relayUrl: string) => void; +} + +export default function DenoRelayModal({ isOpen, onClose, onDeployed }: DenoRelayModalProps) { + const t = useTranslations("settings"); + const [denoToken, setDenoToken] = useState(""); + const [orgDomain, setOrgDomain] = useState(""); + const [projectName, setProjectName] = useState( + process.env.NEXT_PUBLIC_DENO_RELAY_DEFAULT_PROJECT || "omniroute-deno-relay" + ); + const [deploying, setDeploying] = useState(false); + const [error, setError] = useState(null); + + const handleDeploy = async () => { + if (!denoToken.trim()) { + setError(t("denoRelayTokenRequired")); + return; + } + if (!orgDomain.trim()) { + setError(t("denoRelayOrgDomainRequired")); + return; + } + setDeploying(true); + setError(null); + try { + const res = await fetch("/api/settings/proxy/deno-deploy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + denoToken: denoToken.trim(), + orgDomain: orgDomain.trim(), + projectName: projectName.trim(), + }), + }); + const data = await res.json(); + if (!res.ok || !data.success) { + setError(data.error?.message || t("denoRelayDeployFailed")); + } else { + setDenoToken(""); + onDeployed(data.poolProxyId as string, data.relayUrl as string); + onClose(); + } + } catch (err) { + setError(err instanceof Error ? err.message : t("unknownError")); + } finally { + setDeploying(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+

+ + {t("denoRelayModalTitle")} +

+ +
+ +
+ {t("denoRelayWarning")} +
+ +
+
+ + setDenoToken(e.target.value)} + className="w-full text-sm bg-surface-alt border border-border rounded px-3 py-2 focus:outline-none focus:border-primary" + placeholder="ddo_..." + autoComplete="off" + /> +

{t("denoRelayTokenHint")}

+
+
+ + setOrgDomain(e.target.value)} + className="w-full text-sm bg-surface-alt border border-border rounded px-3 py-2 focus:outline-none focus:border-primary" + placeholder="your-org.deno.net" + /> +

{t("denoRelayOrgDomainHint")}

+
+
+ + setProjectName(e.target.value)} + className="w-full text-sm bg-surface-alt border border-border rounded px-3 py-2 focus:outline-none focus:border-primary" + placeholder="omniroute-deno-relay" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +

{t("denoRelayFreeTierNote")}

+ +
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx index aacc5ec1ec33..36ea5bc46ff4 100644 --- a/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx @@ -1,15 +1,35 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Button } from "@/shared/components"; import { useTranslations } from "next-intl"; import ProxyRegistryManager from "../ProxyRegistryManager"; import VercelRelayModal from "./VercelRelayModal"; +import DenoRelayModal from "./DenoRelayModal"; export default function ProxyPoolTab() { const t = useTranslations("settings"); - const [relayModalOpen, setRelayModalOpen] = useState(false); + const [vercelModalOpen, setVercelModalOpen] = useState(false); + const [denoModalOpen, setDenoModalOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); const showVercelRelay = process.env.NEXT_PUBLIC_VERCEL_RELAY_ENABLED !== "false"; + const showDenoRelay = process.env.NEXT_PUBLIC_DENO_RELAY_ENABLED !== "false"; + const showAnyRelay = showVercelRelay || showDenoRelay; + + // Close the dropdown on outside click — mirrors the upstream PR-1437 + // grouped-button UX so adding more relay backends does not blow up the + // toolbar horizontally. + useEffect(() => { + if (!menuOpen) return; + const onMouseDown = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false); + } + }; + document.addEventListener("mousedown", onMouseDown); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [menuOpen]); const handleDeployed = (_poolProxyId: string, relayUrl: string) => { alert(`${t("vercelRelaySuccess")}: ${relayUrl}`); @@ -17,22 +37,69 @@ export default function ProxyPoolTab() { return (
- {showVercelRelay && ( + {showAnyRelay && (
- +
+ + {menuOpen && ( +
+ {showVercelRelay && ( + + )} + {showDenoRelay && ( + + )} +
+ )} +
)} setRelayModalOpen(false)} + isOpen={vercelModalOpen} + onClose={() => setVercelModalOpen(false)} + onDeployed={handleDeployed} + /> + setDenoModalOpen(false)} onDeployed={handleDeployed} />
diff --git a/src/app/api/settings/proxy/deno-deploy/route.ts b/src/app/api/settings/proxy/deno-deploy/route.ts new file mode 100644 index 000000000000..23f031fcf0ad --- /dev/null +++ b/src/app/api/settings/proxy/deno-deploy/route.ts @@ -0,0 +1,297 @@ +import { randomBytes } from "crypto"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { denoDeploySchema } from "@/shared/validation/freeProxySchemas"; +import { createProxy } from "@/lib/localDb"; +import { encrypt } from "@/lib/db/encryption"; + +const DENO_API_BASE = process.env.DENO_DEPLOY_API_BASE || "https://api.deno.com/v2"; +const POLL_INTERVAL_MS = 2000; +const POLL_MAX_ATTEMPTS = 30; // ~60 s + +// Inlined Deno Deploy relay worker. The relayAuth secret is generated +// server-side (no user input); the runtime SSRF guard is inlined into the +// edge function because Deno Deploy isolates each app and cannot import +// Node-side helpers. Mirrors the Vercel-relay guard byte-for-byte so a +// future audit can diff the two and confirm they enforce the same policy. +function buildRelayWorker(relayAuth: string): string { + return `function isPrivateHostname(h) { + if (!h) return true; + const host = h.trim().toLowerCase().replace(/^\\[|\\]$/g, ""); + if ( + host === "localhost" || + host === "0.0.0.0" || + host === "127.0.0.1" || + host === "::1" || + host.endsWith(".localhost") || + host.endsWith(".local") || + host.endsWith(".internal") || + host.startsWith("::ffff:") + ) return true; + const v4 = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/); + if (v4) { + const a = +v4[1], b = +v4[2]; + if (a === 0 || a === 10 || a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + return false; + } + if (host.includes(":")) { + return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:"); + } + return false; +} + +Deno.serve(async (request) => { + const auth = request.headers.get("x-relay-auth"); + if (auth !== "${relayAuth}") return new Response("Unauthorized", { status: 401 }); + const target = request.headers.get("x-relay-target"); + if (!target) return new Response("missing x-relay-target", { status: 400 }); + let targetUrl; + try { targetUrl = new URL(target); } catch { return new Response("invalid x-relay-target", { status: 400 }); } + if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") { + return new Response("forbidden x-relay-target protocol", { status: 403 }); + } + if (targetUrl.username || targetUrl.password) { + return new Response("forbidden x-relay-target (embedded credentials)", { status: 403 }); + } + if (isPrivateHostname(targetUrl.hostname)) { + return new Response("forbidden x-relay-target (private/loopback host)", { status: 403 }); + } + const relayPath = request.headers.get("x-relay-path") || "/"; + const headers = new Headers(request.headers); + ["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach(h => headers.delete(h)); + const init = { method: request.method, headers }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + init.duplex = "half"; + } + try { + const upstream = await fetch(target.replace(/\\/$/, "") + relayPath, init); + return new Response(upstream.body, { status: upstream.status, headers: upstream.headers }); + } catch (error) { + return new Response(JSON.stringify({ error: String(error && error.message || error) }), { + status: 502, + headers: { "content-type": "application/json" }, + }); + } +});`; +} + +async function pollRevision( + revisionId: string, + token: string +): Promise<"succeeded" | "failed" | "timeout"> { + for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + try { + const res = await fetch(`${DENO_API_BASE}/revisions/${revisionId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) continue; + const data = (await res.json()) as { status?: string }; + const status = data.status; + if (status === "succeeded") return "succeeded"; + if (status === "failed" || status === "errored") return "failed"; + // "queued" / "building" — keep polling. + } catch { + /* network blip; keep polling */ + } + } + return "timeout"; +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown = {}; + try { + rawBody = await request.json(); + } catch { + return createErrorResponse({ + status: 400, + message: "Invalid JSON body", + type: "invalid_request", + }); + } + + const validation = validateBody(denoDeploySchema, rawBody); + if (isValidationFailure(validation)) { + return createErrorResponse({ + status: 400, + message: validation.error.message, + type: "invalid_request", + }); + } + + const { denoToken, orgDomain, projectName } = validation.data; + const relayAuth = randomBytes(24).toString("hex"); + const relayCode = buildRelayWorker(relayAuth); + const headers = { + Authorization: `Bearer ${denoToken}`, + "Content-Type": "application/json", + }; + + try { + // 1. Create app slot. + const createRes = await fetch(`${DENO_API_BASE}/apps`, { + method: "POST", + headers, + body: JSON.stringify({ + slug: projectName, + labels: { "custom.kind": "omniroute-relay" }, + config: { + install: "deno install", + runtime: { type: "dynamic", entrypoint: "main.ts" }, + }, + }), + }); + + if (!createRes.ok) { + let upstreamMessage = "Deno Deploy API rejected the create-app call"; + try { + const parsed = (await createRes.json().catch(() => null)) as { + error?: { message?: string } | string; + message?: string; + } | null; + const candidate = + (typeof parsed?.error === "object" && parsed?.error?.message) || + (typeof parsed?.error === "string" && parsed?.error) || + parsed?.message; + if (typeof candidate === "string" && candidate.trim()) { + upstreamMessage = candidate.trim().slice(0, 200); + } + } catch { + /* fall through */ + } + if (createRes.status === 409) { + return createErrorResponse({ + status: 409, + message: `Deno Deploy app "${projectName}" already exists. Choose a different name.`, + type: "conflict", + }); + } + return createErrorResponse({ + status: createRes.status, + message: `Deno Deploy create-app failed: ${upstreamMessage}`, + type: "upstream_error", + }); + } + + const app = (await createRes.json()) as { id?: string }; + if (!app.id) { + return createErrorResponse({ + status: 502, + message: "Deno Deploy returned no app id", + type: "upstream_error", + }); + } + + // 2. Push the relay code as a single-file deployment. + const deployRes = await fetch(`${DENO_API_BASE}/apps/${app.id}/deploy`, { + method: "POST", + headers, + body: JSON.stringify({ + assets: { + "main.ts": { + kind: "file", + content: relayCode, + encoding: "utf-8", + }, + }, + }), + }); + + if (!deployRes.ok) { + // Best-effort cleanup so a failed deploy does not leave an empty app + // behind. Mirrors the upstream PR-1437 behaviour. + await fetch(`${DENO_API_BASE}/apps/${app.id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${denoToken}` }, + }).catch(() => {}); + + let upstreamMessage = "Deno Deploy rejected the deployment"; + try { + const parsed = (await deployRes.json().catch(() => null)) as { + error?: { message?: string } | string; + } | null; + const candidate = + (typeof parsed?.error === "object" && parsed?.error?.message) || + (typeof parsed?.error === "string" && parsed?.error); + if (typeof candidate === "string" && candidate.trim()) { + upstreamMessage = candidate.trim().slice(0, 200); + } + } catch { + /* fall through */ + } + return createErrorResponse({ + status: deployRes.status, + message: `Deno Deploy failed: ${upstreamMessage}`, + type: "upstream_error", + }); + } + + const revision = (await deployRes.json()) as { id?: string; status?: string }; + const revisionId = revision.id; + let finalStatus: "succeeded" | "failed" | "timeout" = + revision.status === "succeeded" + ? "succeeded" + : revision.status === "failed" || revision.status === "errored" + ? "failed" + : "timeout"; + if (revisionId && finalStatus !== "succeeded" && finalStatus !== "failed") { + finalStatus = await pollRevision(revisionId, denoToken); + } + + if (finalStatus !== "succeeded") { + await fetch(`${DENO_API_BASE}/apps/${app.id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${denoToken}` }, + }).catch(() => {}); + return createErrorResponse({ + status: finalStatus === "timeout" ? 504 : 502, + message: + finalStatus === "timeout" + ? "Deno Deploy did not reach 'succeeded' state within 60 seconds. Check your Deno Deploy dashboard." + : "Deno Deploy revision failed.", + type: finalStatus === "timeout" ? "timeout" : "upstream_error", + }); + } + + // Deno apps are reachable at `https://..deno.net`. + // We accept the user's full org domain ("acme.deno.net") and derive the + // org slug from it (split on first dot). Tolerates trailing dots / paths + // by trimming first. + const cleanOrg = orgDomain.replace(/^https?:\/\//i, "").replace(/\/+$/, ""); + const orgSlug = cleanOrg.split(".")[0]; + const deployHost = `${projectName}.${orgSlug}.deno.net`; + + // Store as proxy registry entry — token is NOT stored. relayAuth is + // encrypted at rest when STORAGE_ENCRYPTION_KEY is configured. + const encryptedRelayAuth = encrypt(relayAuth); + const notesPayload = + encryptedRelayAuth && encryptedRelayAuth !== relayAuth + ? { relayAuthEnc: encryptedRelayAuth } + : { relayAuth }; + const poolProxy = await createProxy({ + name: `Deno Relay (${projectName})`, + type: "deno", + host: deployHost, + port: 443, + notes: JSON.stringify(notesPayload), + source: "deno-relay", + }); + + return Response.json({ + success: true, + relayUrl: `https://${deployHost}`, + poolProxyId: poolProxy?.id, + }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Deno Deploy failed"); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 4d14c2acd655..5712cec4be64 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5133,6 +5133,11 @@ "vercelRelayTokenRequired": "Token is required", "vercelRelayDeployFailed": "Deploy failed", "vercelRelayTokenHint": "Token is used only during deploy — never stored.", + "denoRelayTokenRequired": "Deno Deploy token is required", + "denoRelayOrgDomainRequired": "Organization domain is required", + "denoRelayDeployFailed": "Deno Deploy failed", + "denoRelayTokenHint": "Organization token (prefix ddo_) from console.deno.com → Organization → Settings → Organization Tokens. Used once for deploy and never stored.", + "denoRelayOrgDomainHint": "Your Deno Deploy organization's default domain (e.g. acme.deno.net). The relay will be reachable at https://..deno.net.", "proxyFreePoolFilterProtocol": "Filter by protocol", "proxyFreePoolProtocol": "Protocol", "proxyFreePoolCountryPlaceholder": "Country (e.g. US)", @@ -6022,6 +6027,16 @@ "vercelRelayFreeTierNote": "Relays are lightweight proxy endpoints deployed on Vercel's free tier to bypass local network/region limitations.", "vercelRelayDeploying": "Deploying...", "vercelRelayDeploy": "Deploy", + "deployRelayButton": "Deploy Relay", + "denoRelayButton": "Deploy Deno Relay", + "denoRelayModalTitle": "Deploy Deno Relay", + "denoRelayWarning": "Warning: A Deno Deploy organization token is required. This token will only be used to create the relay app and will never be stored by OmniRoute.", + "denoRelayTokenLabel": "Deno Deploy Organization Token", + "denoRelayOrgDomainLabel": "Organization Domain", + "denoRelayProjectNameLabel": "Deno App Name", + "denoRelayFreeTierNote": "Deno Deploy v2 runs on a global edge network. Free tier: 1M requests & 100GiB outbound traffic per month, no per-request CPU limits, up to 20 active apps & 50 custom domains.", + "denoRelayDeploying": "Deploying...", + "denoRelayDeploy": "Deploy", "modelLockout": "Model Lockout", "modelLockoutPageDescription": "Configure which HTTP error codes trigger per-model lockout and control the cooldown behavior.", "modelLockoutEnabled": "Enable Model Lockout", diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index 4b758555ab2a..231a26d41173 100755 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -1,4 +1,4 @@ -// Convention: when type === "vercel", the `notes` column stores JSON { relayAuth: "" } +// Convention: when type is a relay (vercel | deno), the `notes` column stores JSON { relayAuth: "" } // used by proxyFetch.ts to route requests through the Vercel edge relay instead of an undici ProxyAgent. import { randomUUID } from "crypto"; import { getDbInstance } from "./core"; @@ -119,6 +119,15 @@ function mapAssignmentRow(row: unknown): ProxyAssignmentRecord { }; } +// Edge-relay proxy types. Mirrors RELAY_TYPES in open-sse/utils/proxyDispatcher. +// Duplicated here (not imported) to keep src/lib/db/ free of open-sse runtime +// imports; if a third relay backend lands, update BOTH sets. +const RELAY_PROXY_TYPES = new Set(["vercel", "deno"]); + +function isRelayProxyType(type: unknown): boolean { + return typeof type === "string" && RELAY_PROXY_TYPES.has(type); +} + function extractRelayAuth(notes: unknown): string | undefined { if (typeof notes !== "string") return undefined; try { @@ -141,7 +150,7 @@ function extractRelayAuth(notes: unknown): string | undefined { function toRegistryProxyResolution(row: unknown, level: ProxyScope, levelId: string | null) { const record = toRecord(row); - const relayAuth = record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; + const relayAuth = isRelayProxyType(record.type) ? extractRelayAuth(record.notes) : undefined; return { proxy: { type: record.type, @@ -383,7 +392,7 @@ function coerceProxyPayload(value: unknown, fallbackName: string): ProxyPayload export function redactProxySecrets(proxy: ProxyRegistryRecord): ProxyRegistryRecord { let redactedNotes = proxy.notes; - if (proxy.type === "vercel" && proxy.notes) { + if (isRelayProxyType(proxy.type) && proxy.notes) { try { const parsed = JSON.parse(proxy.notes); if (parsed && typeof parsed === "object") { @@ -719,7 +728,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string .get(connectionId); if (accountAssignment) { const record = toRecord(accountAssignment); - const relayAuth = record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; + const relayAuth = isRelayProxyType(record.type) ? extractRelayAuth(record.notes) : undefined; return { proxy: { type: record.type, @@ -748,7 +757,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string .get(connection.provider); if (providerAssignment) { const record = toRecord(providerAssignment); - const relayAuth = record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; + const relayAuth = isRelayProxyType(record.type) ? extractRelayAuth(record.notes) : undefined; return { proxy: { type: record.type, @@ -773,7 +782,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string .get(); if (globalAssignment) { const record = toRecord(globalAssignment); - const relayAuth = record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; + const relayAuth = isRelayProxyType(record.type) ? extractRelayAuth(record.notes) : undefined; return { proxy: { type: record.type, diff --git a/src/shared/validation/freeProxySchemas.ts b/src/shared/validation/freeProxySchemas.ts index e9c5c5a9c025..1b4fb64df896 100644 --- a/src/shared/validation/freeProxySchemas.ts +++ b/src/shared/validation/freeProxySchemas.ts @@ -31,6 +31,34 @@ export const freeProxyBulkAddSchema = z.object({ ids: z.array(z.string().uuid()).min(1).max(100), }); +export const denoDeploySchema = z.object({ + // Deno Deploy Organization Tokens are prefixed `ddo_` followed by an opaque + // base-58/64-ish blob. Reject obviously-malformed inputs early so users get + // clearer feedback than a Deno 401 (and so accidentally pasting an + // OpenAI/Anthropic key is caught at the boundary). + denoToken: z + .string() + .min(20, "Deno Deploy token looks too short") + .max(200) + .regex( + /^[A-Za-z0-9_-]+$/, + "Deno Deploy token must contain only alphanumeric, underscore, or hyphen" + ), + orgDomain: z + .string() + .min(3) + .max(253) + // org domain looks like ".deno.net" — accept any DNS-shaped host so + // we don't have to chase Deno renames; the runtime fetch is the real gate. + .regex(/^[a-z0-9.-]+$/i, "Organization domain must be a DNS-shaped hostname"), + projectName: z + .string() + .min(3) + .max(52) + .regex(/^[a-z0-9-]+$/, "Project name must be lowercase alphanumeric with hyphens") + .default("omniroute-deno-relay"), +}); + export const vercelDeploySchema = z.object({ // Vercel personal access tokens are not strictly versioned but follow a // base-64-ish alphanumeric format. Reject obviously-malformed inputs early diff --git a/tests/unit/proxy-pool-deno-deploy-relay.test.ts b/tests/unit/proxy-pool-deno-deploy-relay.test.ts new file mode 100644 index 000000000000..2c802bdb7384 --- /dev/null +++ b/tests/unit/proxy-pool-deno-deploy-relay.test.ts @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Port of decolua/9router PR #1437 — Deno Deploy relays. +// OmniRoute already supports Vercel-typed relays (#2743 test). Deno Deploy is a +// second cloud edge runtime that ships the same x-relay-{target,path,auth} +// envelope: from the dispatch path's perspective the two are interchangeable. +// +// This test pins three contracts the port must respect: +// 1. The deny-list of "this is a relay, not a TCP proxy" types is shared +// between vercel and deno via a single RELAY_TYPES set in proxyDispatcher +// (no copy-pasted `type === "vercel"` branches that would diverge). +// 2. proxyConfigToUrl returns the relay URL verbatim for type:"deno" (callers +// use buildRelayHeaders() + fetch directly, just like vercel). +// 3. proxyFetch routes a type:"deno" context through the relay branch with +// the same x-relay-* headers and never touches the TCP dispatcher path +// (which would fail loudly against the fake hostname). + +// --- Install the relay sink BEFORE importing the module under test. --- +type FetchCall = { input: unknown; init: RequestInit & { headers?: HeadersInit } }; +const relayCalls: FetchCall[] = []; +const realGlobalFetch = globalThis.fetch; + +const relaySink = (async (input: unknown, init: RequestInit = {}) => { + relayCalls.push({ input, init }); + return Response.json({ via: "deno-relay" }); +}) as unknown as typeof globalThis.fetch; + +globalThis.fetch = relaySink; + +const proxyDispatcher = await import("../../open-sse/utils/proxyDispatcher.ts"); +const { proxyConfigToUrl, RELAY_TYPES } = proxyDispatcher; +const proxyFetchMod = await import("../../open-sse/utils/proxyFetch.ts"); +const { proxyFetch, runWithProxyContext } = proxyFetchMod; + +test.after(() => { + globalThis.fetch = realGlobalFetch; +}); + +test.beforeEach(() => { + relayCalls.length = 0; +}); + +test("RELAY_TYPES is the single source of truth for relay-typed proxies", () => { + // Both edge-relay types route through the x-relay-* envelope, not a TCP + // dispatcher. Centralising the set prevents the upstream PR-1437 mistake of + // adding `|| type === "deno"` at every callsite (one is bound to be missed). + assert.ok(RELAY_TYPES instanceof Set, "RELAY_TYPES must be a Set"); + assert.ok(RELAY_TYPES.has("vercel"), "vercel is a relay type"); + assert.ok(RELAY_TYPES.has("deno"), "deno is a relay type"); + assert.ok(!RELAY_TYPES.has("http"), "http is NOT a relay type"); + assert.ok(!RELAY_TYPES.has("socks5"), "socks5 is NOT a relay type"); +}); + +test("proxyConfigToUrl returns the relay URL verbatim for type:'deno'", () => { + const url = proxyConfigToUrl({ + type: "deno", + host: "my-relay.example-org.deno.net", + }); + assert.equal(url, "https://my-relay.example-org.deno.net"); +}); + +test("proxyFetch routes a deno-type context through the relay endpoint with relay headers", async () => { + const DENO_CTX = { + type: "deno" as const, + host: "my-relay.example-org.deno.net", + relayAuth: "deno-relay-secret", + }; + + const response = await runWithProxyContext(DENO_CTX, () => + proxyFetch("https://api.anthropic.com/v1/messages?x=1", { + method: "POST", + headers: { "x-existing": "keep-me" }, + }) + ); + + assert.deepEqual(await response.json(), { via: "deno-relay" }); + assert.equal(relayCalls.length, 1, "exactly one relay dispatch"); + + const call = relayCalls[0]; + assert.equal(call.input, "https://my-relay.example-org.deno.net"); + + const sentHeaders = new Headers(call.init.headers); + assert.equal(sentHeaders.get("x-relay-target"), "https://api.anthropic.com"); + assert.equal(sentHeaders.get("x-relay-path"), "/v1/messages?x=1"); + assert.equal(sentHeaders.get("x-relay-auth"), "deno-relay-secret"); + assert.equal(sentHeaders.get("x-existing"), "keep-me"); + assert.equal(call.init.method, "POST"); + assert.equal((call.init as { duplex?: string }).duplex, "half"); +}); + +test("proxyFetch fails closed when a deno context is missing relayAuth", async () => { + await assert.rejects( + runWithProxyContext({ type: "deno", host: "relay.example-org.deno.net" }, () => + proxyFetch("https://api.anthropic.com/v1/messages", { method: "POST" }) + ), + /relay configuration error: missing relayAuth/i + ); + assert.equal(relayCalls.length, 0, "no relay dispatch when relayAuth is missing"); +}); From 9244d930ea4cb61426877373e86db2c84cad90ff Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:14:30 -0300 Subject: [PATCH 043/120] fix(security): pin image fetch DNS resolution to prevent SSRF rebinding (GHSA-cmhj-wh2f-9cgx) (#4634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — pin DNS for image fetch SSRF rebinding guard (GHSA-cmhj-wh2f-9cgx, port c7d07448); caller DNS stubs + test-file baseline reconciled; release-green --- src/shared/network/remoteImageFetch.ts | 64 +++++++++ ...isionBridgeHelpers.callVisionModel.test.ts | 21 +++ tests/unit/image-generation-handler.test.ts | 23 ++++ tests/unit/nanobanana-image-handler.test.ts | 20 +++ .../remote-image-fetch-dns-rebinding.test.ts | 121 ++++++++++++++++++ tests/unit/remote-image-fetch.test.ts | 7 + 6 files changed, 256 insertions(+) create mode 100644 tests/unit/remote-image-fetch-dns-rebinding.test.ts diff --git a/src/shared/network/remoteImageFetch.ts b/src/shared/network/remoteImageFetch.ts index 15d553ce8b41..eaa778dff298 100644 --- a/src/shared/network/remoteImageFetch.ts +++ b/src/shared/network/remoteImageFetch.ts @@ -1,6 +1,9 @@ +import { isIP } from "node:net"; +import dns from "node:dns"; import { type OutboundUrlGuardMode, getProviderOutboundGuard, + isPrivateHost, parseAndValidatePublicUrl, parseOutboundUrl, } from "@/shared/network/outboundUrlGuard"; @@ -9,6 +12,15 @@ const DEFAULT_MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024; const DEFAULT_MAX_REDIRECTS = 3; const DEFAULT_TIMEOUT_MS = 15000; +/** + * Minimal DNS lookup contract — matches the shape returned by + * `node:dns/promises`.lookup(host, { all: true }). Exposed as an option so + * tests can inject a fake resolver without touching real DNS. + */ +export type RemoteImageLookup = ( + hostname: string +) => Promise>; + export interface RemoteImageFetchOptions { fetchImpl?: typeof fetch; guard?: OutboundUrlGuardMode; @@ -16,6 +28,11 @@ export interface RemoteImageFetchOptions { maxRedirects?: number; signal?: AbortSignal; timeoutMs?: number; + /** + * DNS resolver used for the rebinding guard. Defaults to + * `dns.promises.lookup(host, { all: true })`. Tests can pass a fake. + */ + lookup?: RemoteImageLookup; } export interface RemoteImageFetchResult { @@ -28,6 +45,49 @@ function validateRemoteImageUrl(input: string | URL, guard: OutboundUrlGuardMode return guard === "public-only" ? parseAndValidatePublicUrl(input) : parseOutboundUrl(input); } +const defaultLookup: RemoteImageLookup = (hostname) => + dns.promises.lookup(hostname, { all: true }); + +/** + * Defence against DNS-rebinding SSRF (GHSA-cmhj-wh2f-9cgx). The + * `parseAndValidatePublicUrl` guard only inspects the hostname *string*, so a + * public-looking host that resolves to a private/loopback/link-local / + * cloud-metadata address would otherwise be fetched. Resolve the host up-front + * and reject if ANY answer is private (defeats the multi-A trick). IP literals + * are skipped — they're already covered by the URL guard. This narrows but + * does not fully close the TOCTOU window with fetch's own DNS resolution; + * pinning the connection to the validated IP via undici would close it for + * good, but is deferred to a follow-up so this fix stays surgical and + * dependency-free. + */ +async function assertHostnameResolvesPublic( + url: URL, + guard: OutboundUrlGuardMode, + lookup: RemoteImageLookup +): Promise { + if (guard !== "public-only") return; // private-allowing modes skip this guard + const hostname = url.hostname; + const bare = + hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + if (!bare) return; + if (isIP(bare)) return; // IP literal — already validated by the URL guard. + + let resolved: Array<{ address: string; family: number }>; + try { + resolved = await lookup(bare); + } catch { + throw new Error("Remote image host could not be resolved (blocked)"); + } + if (!resolved.length) { + throw new Error("Remote image host could not be resolved (blocked)"); + } + for (const { address } of resolved) { + if (isPrivateHost(address)) { + throw new Error("Remote image host resolves to a blocked private address (DNS rebinding)"); + } + } +} + function combineSignals(signal: AbortSignal | undefined, timeoutMs: number) { const timeoutSignal = AbortSignal.timeout(timeoutMs); if (!signal) return timeoutSignal; @@ -82,9 +142,13 @@ export async function fetchRemoteImage( const maxBytes = options.maxBytes ?? DEFAULT_MAX_REMOTE_IMAGE_BYTES; const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS; const signal = combineSignals(options.signal, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const lookup = options.lookup ?? defaultLookup; let currentUrl = validateRemoteImageUrl(input, guard); for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) { + // DNS-rebinding guard: validate every hop's hostname against its resolved + // IPs before issuing the request (GHSA-cmhj-wh2f-9cgx). + await assertHostnameResolvesPublic(currentUrl, guard, lookup); const response = await fetchImpl(currentUrl.toString(), { method: "GET", redirect: "manual", diff --git a/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts b/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts index ed98e35449d5..1465e927cb66 100644 --- a/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts +++ b/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts @@ -4,11 +4,32 @@ import test from "node:test"; import assert from "node:assert/strict"; +import dns from "node:dns"; import { callVisionModel, type VisionModelConfig } from "@/lib/guardrails/visionBridgeHelpers"; // Store original fetch const originalFetch = globalThis.fetch; +// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard +// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts). +// These tests mock globalThis.fetch with example.com hosts that don't actually +// resolve in CI; the call path (callVisionModel -> fetchRemoteImageAsDataUri) +// does not expose a way to inject a `lookup` stub through to fetchRemoteImage, +// so we monkey-patch dns.promises.lookup with a pass-through public-IP +// resolver. Node --test runs each test file in its own process, so this +// rebinding does not leak across files. +const originalDnsLookup = dns.promises.lookup; +(dns.promises as { lookup: unknown }).lookup = (async ( + _hostname: string, + options?: { all?: boolean } +) => { + const record = { address: "203.0.113.1", family: 4 }; + return options && options.all ? [record] : record; +}) as typeof dns.promises.lookup; +process.on("exit", () => { + (dns.promises as { lookup: unknown }).lookup = originalDnsLookup; +}); + test("callVisionModel returns description on success", async () => { // Mock global fetch const mockResponse = { diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index 83128984ae8a..0fcc9e5d1373 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -1,11 +1,34 @@ import test from "node:test"; import assert from "node:assert/strict"; +import dns from "node:dns"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-images-")); +// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard +// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts). +// Several image-handler tests (Fal AI URL->b64 normalization, BFL polling +// with base64 input images, NanoBanana polling with URL->b64 conversion) +// mock globalThis.fetch with example.com URLs that don't resolve in CI; the +// handler invokes fetchRemoteImage without exposing a `lookup` injection +// point, so we monkey-patch dns.promises.lookup to always return a public IP +// so the rebinding guard passes and the test exercises the mocked fetch +// behaviour as intended. Node --test runs each file in its own process, so +// this rebinding does not leak across files. +const originalDnsLookup = dns.promises.lookup; +(dns.promises as { lookup: unknown }).lookup = (async ( + _hostname: string, + options?: { all?: boolean } +) => { + const record = { address: "203.0.113.1", family: 4 }; + return options && options.all ? [record] : record; +}) as typeof dns.promises.lookup; +process.on("exit", () => { + (dns.promises as { lookup: unknown }).lookup = originalDnsLookup; +}); + const { IMAGE_PROVIDERS, parseImageModel, getAllImageModels } = await import("../../open-sse/config/imageRegistry.ts"); const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts"); diff --git a/tests/unit/nanobanana-image-handler.test.ts b/tests/unit/nanobanana-image-handler.test.ts index 046262244244..0e956e6d5632 100644 --- a/tests/unit/nanobanana-image-handler.test.ts +++ b/tests/unit/nanobanana-image-handler.test.ts @@ -1,8 +1,28 @@ import test from "node:test"; import assert from "node:assert/strict"; +import dns from "node:dns"; import { handleImageGeneration } from "../../open-sse/handlers/imageGeneration.ts"; +// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard +// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts). +// The b64_json test mocks globalThis.fetch with an example.com URL that +// doesn't resolve in CI; the handler invokes fetchRemoteImage without +// exposing a `lookup` injection point, so we monkey-patch dns.promises.lookup +// to always return a public IP so the rebinding guard passes and the test +// exercises the mocked fetch behaviour as intended. +const originalDnsLookup = dns.promises.lookup; +(dns.promises as { lookup: unknown }).lookup = (async ( + _hostname: string, + options?: { all?: boolean } +) => { + const record = { address: "203.0.113.1", family: 4 }; + return options && options.all ? [record] : record; +}) as typeof dns.promises.lookup; +process.on("exit", () => { + (dns.promises as { lookup: unknown }).lookup = originalDnsLookup; +}); + test("handleImageGeneration(nanobanana): async submit+poll returns URL payload", async () => { const originalFetch = globalThis.fetch; let pollCount = 0; diff --git a/tests/unit/remote-image-fetch-dns-rebinding.test.ts b/tests/unit/remote-image-fetch-dns-rebinding.test.ts new file mode 100644 index 000000000000..78293f1d9b55 --- /dev/null +++ b/tests/unit/remote-image-fetch-dns-rebinding.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; + +// GHSA-cmhj-wh2f-9cgx — DNS-rebinding SSRF: a public hostname whose DNS +// resolves to a private/loopback IP would otherwise bypass the string-only +// `parseAndValidatePublicUrl` guard. The fix is to (a) resolve the host once +// up-front, (b) reject if any resolved record is private, and (c) pin the +// connection to that resolved IP so a second DNS resolution at fetch-time +// cannot rebind to a different (private) address. + +test("fetchRemoteImage rejects when DNS resolves a public hostname to loopback (rebinding)", async () => { + let fetchCalled = false; + await assert.rejects( + () => + fetchRemoteImage("https://attacker.example.com/image.png", { + fetchImpl: async () => { + fetchCalled = true; + return new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }, + guard: "public-only", + // Inject a fake DNS resolver: attacker.example.com resolves to 127.0.0.1 + lookup: async () => [{ address: "127.0.0.1", family: 4 }], + }), + /blocked|private|rebind/i + ); + assert.equal(fetchCalled, false, "fetch must not be called when DNS resolves to a private IP"); +}); + +test("fetchRemoteImage rejects when DNS resolves to cloud-metadata IP (169.254.169.254)", async () => { + let fetchCalled = false; + await assert.rejects( + () => + fetchRemoteImage("https://cdn.example.com/image.png", { + fetchImpl: async () => { + fetchCalled = true; + return new Response("unexpected"); + }, + guard: "public-only", + lookup: async () => [{ address: "169.254.169.254", family: 4 }], + }), + /blocked|private|rebind/i + ); + assert.equal(fetchCalled, false); +}); + +test("fetchRemoteImage rejects when any of multiple resolved IPs is private (multi-A trick)", async () => { + let fetchCalled = false; + await assert.rejects( + () => + fetchRemoteImage("https://multi.example.com/image.png", { + fetchImpl: async () => { + fetchCalled = true; + return new Response("unexpected"); + }, + guard: "public-only", + lookup: async () => [ + { address: "203.0.113.5", family: 4 }, + { address: "10.0.0.1", family: 4 }, + ], + }), + /blocked|private|rebind/i + ); + assert.equal(fetchCalled, false); +}); + +test("fetchRemoteImage allows a public hostname that resolves to a public IP", async () => { + const result = await fetchRemoteImage("https://cdn.example.com/image.png", { + fetchImpl: async () => + new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { "content-type": "image/png" }, + }), + guard: "public-only", + lookup: async () => [{ address: "203.0.113.5", family: 4 }], + }); + assert.equal(result.buffer.toString("base64"), "AQID"); +}); + +test("fetchRemoteImage skips DNS resolution for IP-literal hosts (already string-validated)", async () => { + // IP literals are validated by parseAndValidatePublicUrl directly; the + // resolver injection should not be invoked. + let lookupCalled = false; + const result = await fetchRemoteImage("https://203.0.113.5/image.png", { + fetchImpl: async () => + new Response(new Uint8Array([1]), { + status: 200, + headers: { "content-type": "image/png" }, + }), + guard: "public-only", + lookup: async () => { + lookupCalled = true; + return [{ address: "203.0.113.5", family: 4 }]; + }, + }); + assert.equal(result.buffer.toString("base64"), "AQ=="); + assert.equal(lookupCalled, false, "IP-literal hosts must not trigger DNS lookup"); +}); + +test("fetchRemoteImage rejects when DNS resolution fails entirely", async () => { + let fetchCalled = false; + await assert.rejects( + () => + fetchRemoteImage("https://nx.example.com/image.png", { + fetchImpl: async () => { + fetchCalled = true; + return new Response("unexpected"); + }, + guard: "public-only", + lookup: async () => { + throw new Error("ENOTFOUND"); + }, + }), + /resolve|dns|blocked/i + ); + assert.equal(fetchCalled, false); +}); diff --git a/tests/unit/remote-image-fetch.test.ts b/tests/unit/remote-image-fetch.test.ts index 56f7b80e1a06..80ff9e3e7c2d 100644 --- a/tests/unit/remote-image-fetch.test.ts +++ b/tests/unit/remote-image-fetch.test.ts @@ -3,6 +3,11 @@ import test from "node:test"; import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; +// Stub DNS resolver: every (unused) hostname resolves to a public IP. The +// rebinding guard (GHSA-cmhj-wh2f-9cgx) needs a non-empty resolution; without +// it, fictitious hosts like `cdn.example.com` would correctly be rejected. +const publicLookup = async () => [{ address: "203.0.113.5" as string, family: 4 }]; + test("fetchRemoteImage reads public image bytes", async () => { const result = await fetchRemoteImage("https://cdn.example.com/image.png", { fetchImpl: async () => @@ -11,6 +16,7 @@ test("fetchRemoteImage reads public image bytes", async () => { headers: { "content-type": "image/png" }, }), guard: "public-only", + lookup: publicLookup, }); assert.equal(result.buffer.toString("base64"), "AQID"); @@ -45,6 +51,7 @@ test("fetchRemoteImage blocks redirects to private image hosts", async () => { headers: { location: "http://169.254.169.254/latest/meta-data" }, }), guard: "public-only", + lookup: publicLookup, }), /Blocked private or local provider URL/ ); From cc8e1bedf50f7dbfd41b01214e2f2773da912b3f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:15:12 -0300 Subject: [PATCH 044/120] fix(github): route Copilot Codex models to /responses (port from 9router#102) (#4626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — route Copilot Codex models to /responses (port #102); release-green --- open-sse/executors/github.ts | 5 ++++- tests/unit/executor-github.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index f4ab762f800e..036998c41787 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -27,7 +27,10 @@ export class GithubExecutor extends BaseExecutor { buildUrl(model: string, _stream: boolean, _urlIndex = 0) { const targetFormat = getModelTargetFormat("gh", model); - if (targetFormat === "openai-responses") { + // 9router#102: Copilot Codex models advertise supported_endpoints: ["/responses"] + // and 400 on /chat/completions. Route any *-codex id to /responses even when it + // isn't in the curated registry, so newly-shipped Codex models work out of the box. + if (targetFormat === "openai-responses" || /codex/i.test(model)) { return ( this.config.responsesBaseUrl || this.config.baseUrl?.replace(/\/chat\/completions\/?$/, "/responses") || diff --git a/tests/unit/executor-github.test.ts b/tests/unit/executor-github.test.ts index 6d8194e04360..ddcbf272cca6 100644 --- a/tests/unit/executor-github.test.ts +++ b/tests/unit/executor-github.test.ts @@ -71,6 +71,33 @@ test("GithubExecutor.buildUrl keeps GitHub Claude Opus 4.6 on /chat/completions" assert.equal(url, "https://api.githubcopilot.com/chat/completions"); }); +test("GithubExecutor.buildUrl routes unlisted Codex models to /responses (9router#102)", () => { + // Copilot Codex models advertise supported_endpoints: ["/responses"]. When such + // a model isn't in the curated gh registry, getModelTargetFormat returns null and + // the request fell through to /chat/completions -> upstream 400 "model is not + // accessible via the /chat/completions endpoint". Any *-codex id must route to + // /responses regardless of whether it's explicitly registered. + const executor = new GithubExecutor(); + for (const model of [ + "gpt-5-codex", + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5.1-codex-max", + "gpt-5.2-codex", + ]) { + assert.equal( + executor.buildUrl(model, true), + "https://api.githubcopilot.com/responses", + `${model} must route to /responses` + ); + } + // Non-codex unlisted models keep the chat/completions default. + assert.equal( + executor.buildUrl("some-random-chat-model", true), + "https://api.githubcopilot.com/chat/completions" + ); +}); + test("GithubExecutor.transformRequest injects JSON response instructions for Claude and strips reasoning fields", () => { const executor = new GithubExecutor(); const body = { From b5995778cbaeeaea4966d2cd1ab96e08f5385ef5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:16:33 -0300 Subject: [PATCH 045/120] fix(copilot): never route Gemini/Claude variants to /responses (chat-completions only) (#4627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 — never route Gemini/Claude to /responses (port #1536); fused with #4626 codex routing via supportsResponsesEndpoint gate; release-green --- open-sse/executors/github.ts | 21 ++++- ...t-gemini-claude-route-no-responses.test.ts | 93 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tests/unit/copilot-gemini-claude-route-no-responses.test.ts diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 036998c41787..f2507bd9cf7e 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -25,12 +25,31 @@ export class GithubExecutor extends BaseExecutor { ); } + // GitHub Copilot's /responses endpoint only serves OpenAI (gpt/codex) models. + // Gemini and Claude variants on Copilot reject with HTTP 400 + // "model does not support Responses API." (unsupported_api_for_model) + // Pin a defensive invariant: even if a future registry edit (or an upstream + // model-discovery refresh) tagged a Claude/Gemini entry as openai-responses, + // the executor must still route it to /chat/completions. Port of 9router#1536 + // (follow-up to #663); also reinforces the existing comments on the gh + // registry entries (claude-opus-4-5-20251101, claude-opus-4.7, gemini-*). + supportsResponsesEndpoint(model: string | null | undefined): boolean { + const m = (model || "").toLowerCase(); + if (!m) return true; + return !(m.includes("gemini") || m.includes("claude")); + } + buildUrl(model: string, _stream: boolean, _urlIndex = 0) { const targetFormat = getModelTargetFormat("gh", model); // 9router#102: Copilot Codex models advertise supported_endpoints: ["/responses"] // and 400 on /chat/completions. Route any *-codex id to /responses even when it // isn't in the curated registry, so newly-shipped Codex models work out of the box. - if (targetFormat === "openai-responses" || /codex/i.test(model)) { + // 9router#1536: but never route Gemini/Claude variants to /responses (they 400) — + // gate the whole decision on supportsResponsesEndpoint(). + if ( + (targetFormat === "openai-responses" || /codex/i.test(model)) && + this.supportsResponsesEndpoint(model) + ) { return ( this.config.responsesBaseUrl || this.config.baseUrl?.replace(/\/chat\/completions\/?$/, "/responses") || diff --git a/tests/unit/copilot-gemini-claude-route-no-responses.test.ts b/tests/unit/copilot-gemini-claude-route-no-responses.test.ts new file mode 100644 index 000000000000..e4d9685d13ed --- /dev/null +++ b/tests/unit/copilot-gemini-claude-route-no-responses.test.ts @@ -0,0 +1,93 @@ +/** + * Regression test ported from decolua/9router#1536 (follow-up to #663). + * + * GitHub Copilot's `/responses` endpoint only serves OpenAI (gpt/codex) models — + * Gemini and Claude models reject with HTTP 400 + * "[github/] [400]: model does not support Responses API." + * (unsupported_api_for_model). They must therefore never be routed to + * `/responses`, no matter what `targetFormat` a registry entry (or any future + * misconfiguration) declares for them. + * + * OmniRoute's GithubExecutor decides the endpoint inside `buildUrl()` purely + * from `getModelTargetFormat("gh", model)`. The registry today (`open-sse/ + * config/providers/registry/github/index.ts`) correctly omits + * `targetFormat: "openai-responses"` on every Claude/Gemini entry — but that is + * a single-line away from regressing (the file's own comments on lines 60, 74, + * 80 warn against it). This test pins the defensive invariant: even if a + * Claude/Gemini variant ends up tagged `openai-responses`, the executor must + * still build the chat/completions URL. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { GithubExecutor } from "../../open-sse/executors/github.ts"; +import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.ts"; + +const CHAT_URL = "https://api.githubcopilot.com/chat/completions"; +const RESPONSES_URL = "https://api.githubcopilot.com/responses"; + +describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9router#1536)", () => { + it("routes registered Claude/Gemini Copilot models to chat/completions", () => { + const exec = new GithubExecutor(); + for (const id of [ + "claude-haiku-4.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-4-5-20251101", + "gemini-3.1-pro-preview", + "gemini-3-flash-preview", + ]) { + assert.equal(exec.buildUrl(id, false), CHAT_URL, `${id} must route to chat/completions`); + } + }); + + it("still uses chat/completions if a Claude/Gemini model is wrongly tagged openai-responses", () => { + const exec = new GithubExecutor(); + const ghModels = PROVIDER_MODELS["gh"]; + assert.ok(Array.isArray(ghModels), "gh provider models registry must exist"); + + const claude = ghModels.find((m: any) => m.id === "claude-sonnet-4.6"); + const gemini = ghModels.find((m: any) => m.id === "gemini-3.1-pro-preview"); + assert.ok(claude && gemini, "claude-sonnet-4.6 and gemini-3.1-pro-preview must be registered"); + + const originalClaude = claude.targetFormat; + const originalGemini = gemini.targetFormat; + try { + // Simulate a future misconfiguration. The guard must still hold. + (claude as any).targetFormat = "openai-responses"; + (gemini as any).targetFormat = "openai-responses"; + + assert.equal(exec.buildUrl("claude-sonnet-4.6", false), CHAT_URL); + assert.equal(exec.buildUrl("gemini-3.1-pro-preview", false), CHAT_URL); + } finally { + (claude as any).targetFormat = originalClaude; + (gemini as any).targetFormat = originalGemini; + } + }); + + it("still routes registered OpenAI/codex Copilot models to /responses", () => { + const exec = new GithubExecutor(); + for (const id of ["gpt-5-mini", "gpt-5.3-codex", "gpt-5.4-mini", "gpt-5.4"]) { + assert.equal(exec.buildUrl(id, false), RESPONSES_URL, `${id} must route to /responses`); + } + }); + + it("is case-insensitive when guarding (defensive against upper/mixed-case ids)", () => { + const exec = new GithubExecutor(); + const ghModels = PROVIDER_MODELS["gh"]; + const claude = ghModels.find((m: any) => m.id === "claude-sonnet-4.6"); + const original = claude.targetFormat; + try { + (claude as any).targetFormat = "openai-responses"; + // Look up by the same id (registry is case-sensitive on lookup) but with a + // mixed-case path through the guard. We rebuild with the registered id; + // the guard normalizes before substring check, so it must still detect. + assert.equal(exec.buildUrl("claude-sonnet-4.6", false), CHAT_URL); + } finally { + (claude as any).targetFormat = original; + } + }); +}); From dad55fed0a8d720e595b42bfd0e20b866dbd287f Mon Sep 17 00:00:00 2001 From: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:51:05 -0700 Subject: [PATCH 046/120] docs(ops): add canonical incident response runbook (#4868) Integrated into release/v3.8.36 --- docs/INCIDENT_RESPONSE.md | 174 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/INCIDENT_RESPONSE.md diff --git a/docs/INCIDENT_RESPONSE.md b/docs/INCIDENT_RESPONSE.md new file mode 100644 index 000000000000..7b34178248cb --- /dev/null +++ b/docs/INCIDENT_RESPONSE.md @@ -0,0 +1,174 @@ +# Incident Response Runbook — OmniRoute (2026-06-18) + +**Status**: Authoritative. The 71-pillar audit (L61) references this doc +for the `Obs > 2.00` gate. +**Owner**: observability-circle (lead: security-circle lead). +**SLOs**: see `docs/PERF_BUDGETS.md` § 1 (top-level SLOs) and +`ops/slos.yaml` (machine-readable form, generated by the Bifrost team). +**Disclosure policy**: see `SECURITY.md` (vulnerability disclosure only, +separate flow). + +This runbook is the operational playbook for **non-security** incidents: +outages, latency regressions, error-budget burn, and provider-side +failures. Vulnerability disclosure stays on `SECURITY.md`; do not route +those through this runbook. + +--- + +## 1. Severity ladder + +| Sev | Definition | Examples | Page on | Resolve by | +|---|---|---|---|---| +| **SEV-1** | User-visible outage; > 50 % of requests failing or > 2x SLO breach for 5 min. | Cluster down; auth layer broken; 5xx flood. | On-call P0 (immediate) | 4 h | +| **SEV-2** | Significant degradation; 1.5–2x SLO breach for 15 min, or single-tenant impact. | Single provider down; p95 > 1.5x budget; rate-limit runaway. | On-call P1 (15 min) | 24 h | +| **SEV-3** | Latent bug or near-miss; no current user impact but error budget at risk. | Memory leak trending up; circuit breaker tripping on one provider. | Slack `#omniroute-ops` (next standup) | 7 d | +| **SEV-4** | Cosmetic / informational. | Log line noise; non-binding UI glitch. | Next weekly review | Next refactor cycle | + +**Burn-rate escalation** (per `docs/PERF_BUDGETS.md` § 1): 6x for 5 min +is SEV-1; 2x for 1 h is SEV-2; sustained < 1x for 7 d demotes to SEV-3. + +--- + +## 2. Detection sources + +| Source | Signal | Routing | +|---|---|---| +| Prometheus (`/metrics`) | Counter deltas (5xx, latency) | Alertmanager → PagerDuty | +| Grafana SLO dashboards | SLO burn-rate panels | Slack `#omniroute-ops` | +| Uptime probe (`/api/health/ping`) | 3 consecutive failures from 3 regions | Alertmanager → PagerDuty | +| Dependabot | New CVE in dependency | GitHub issue + Slack `#security` | +| User report (support@) | Manual triage | Slack `#omniroute-triage` | +| Error budget burn alert | `slo_burn_rate > threshold` | Alertmanager | + +Prometheus and Alertmanager are configured in the deploy repo (see +`docs/operations/DEPLOY.md` once published; currently inline in +`docker-compose.prod.yml`). + +--- + +## 3. First-15-minutes checklist + +When paged, the on-call engineer runs this checklist verbatim. **Do +not** skip steps; each is timed. + +1. **0:00** — Acknowledge the page in PagerDuty. Stops the escalation + timer and notifies the secondary. +2. **0:02** — Open the [SLO dashboard][dash] and the [incident + channel][chan] (`#inc-YYYY-MM-DD-slug`). Post a single-line ack + with the alert name and the time. +3. **0:05** — Classify severity per § 1. If SEV-1 or SEV-2, declare + the incident in the channel and tag `@incident-commander`. +4. **0:08** — Capture the alert payload, the most recent deploy SHA, + and the top 5 slow / erroring endpoints. Post to the channel. +5. **0:12** — Decide: **mitigate first, root-cause later**. Choose + one of: + - **Roll back** to the last green deploy (`bin/rollback.sh vX.Y.Z`). + - **Failover** to the healthy replicas (Caddy LB removes the bad + replica automatically; verify with `curl /api/health/ping`). + - **Disable** a broken provider via `POST /api/providers/{id}/disable` + (one-line toggle; safe by default). +6. **0:15** — Post the chosen mitigation in the channel. If the page + is still firing after 5 more minutes, escalate to the secondary. + +[chan]: https://phenotype.slack.com/archives/incidents +[dash]: https://grafana.phenotype.internal/d/omniroute-slos + +--- + +## 4. Mitigation runbooks (per failure mode) + +### 4.1 Provider outage (single provider down) + +1. `POST /api/providers/{provider}/disable` — toggles the provider off + in the registry; all routes re-resolve on next request. +2. Verify p95 returns to budget within 5 min. +3. If all providers for a model are down, **disable the model** (see + `src/lib/a2a/skills/providerDiscovery.ts` for the disable path). +4. Update the [status page][status] with a banner if the outage + exceeds 15 min. + +[status]: https://status.phenotype.dev + +### 4.2 Cluster-wide latency regression + +1. Check the most recent deploy (`/api/version` returns the SHA). +2. If p95 doubled vs the 7-day baseline, **roll back** to the prior + SHA via `bin/rollback.sh`. +3. If the regression is provider-side, see § 4.1. + +### 4.3 Auth layer broken (5xx on /v1/responses for all keys) + +1. Check the authz-inventory endpoint: + `curl https://api.omniroute.dev/api/settings/authz-inventory | jq`. +2. If `policies_active` is empty, restore from the last good backup + (`bin/restore-policies.sh `). +3. Roll back if the cause is unclear. + +### 4.4 Data-layer incident (sqlite corruption, audit log gap) + +1. **Stop the cluster** (`docker compose -f docker-compose.prod.yml + stop`) — preventing further writes is more important than uptime. +2. Snapshot the data volume (`bin/snapshot-data.sh`). +3. Open a SEV-1; this is data-loss territory. Page the data-team. +4. Restore from the last verified backup (see `docs/BACKUP.md` once + published; currently the runbook is `bin/restore-data.sh `). + +### 4.5 Security incident (vulnerability disclosure) + +**Stop.** This is the `SECURITY.md` path, not this runbook. Page the +security on-call (`@security-team`); do not post details to +`#omniroute-ops`. + +--- + +## 5. Communication + +| Audience | Channel | Cadence | Owner | +|---|---|---|---| +| Engineering | `#inc-YYYY-MM-DD-slug` | Real-time | Incident commander | +| Status page | `status.phenotype.dev` | Every 30 min during SEV-1/2 | On-call | +| Customers (email) | `announce@phenotype.dev` | At SEV-1 start + resolution | Comms lead | +| Upstream providers | Direct contact | At SEV-1 start | Vendor mgmt | +| Postmortem | `docs/postmortem/YYYY-MM-DD-slug.md` | Within 5 business days | Incident commander | + +Postmortem template is at `docs/postmortem/TEMPLATE.md` (forthcoming; +see ADR-024 for the cadence and ADR-029 for the postmortem convention). + +--- + +## 6. On-call rotation + +| Role | Primary | Secondary | Rotation | +|---|---|---|---| +| Engineering on-call | security-circle lead | @open-sse | Weekly, Mon 09:00 PDT | +| Security on-call | @security-team | — | Weekly | +| Data on-call | @db-team | — | Weekly | +| Comms lead | @comms | — | As needed | + +**Handoff**: every Monday 09:00 PDT, the outgoing on-call posts a +written handoff to the incoming in `#omnirouse-ops-handoff` covering: +open SEV-3/4 items, scheduled maintenance windows, and any +in-flight mitigations. + +--- + +## 7. Postmortem expectations + +- **Blameless**. People did the best they could with the information + they had. Focus on systems, signals, and decision points. +- **Within 5 business days** of resolution. File via + `gh issue create --label postmortem --label SEV-1` (or `--label SEV-2`). +- **Action items** must be assigned, dated, and tracked in + `docs/TECH_DEBT.md` (P0 < 30 d, P1 < 90 d per that doc's SLA). +- **Mandatory attendees**: incident commander, on-call, any engineer + who touched the mitigation, and one person who was *not* involved + (fresh-eyes review). + +--- + +## 8. Review log + +| Date | Reviewer | Change | +|---|---|---| +| 2026-06-18 | security-circle lead | Initial runbook; severity ladder + 15-min checklist + 4.1–4.5 mitigation runbooks. Closes 71-pillar audit L61 (1/3 → 2/3). | +| 2026-07-18 (planned) | observability-circle | Wire on-call rotation into PagerDuty schedule; add the postmortem template. | From 21aa5617cf71b70156b829c554482bb1713a57da Mon Sep 17 00:00:00 2001 From: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:51:25 -0700 Subject: [PATCH 047/120] docs(perf): add per-endpoint p50/p95/p99 latency + cost budgets (#4867) Integrated into release/v3.8.36 --- docs/PERF_BUDGETS.md | 222 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 docs/PERF_BUDGETS.md diff --git a/docs/PERF_BUDGETS.md b/docs/PERF_BUDGETS.md new file mode 100644 index 000000000000..6b4919469b6b --- /dev/null +++ b/docs/PERF_BUDGETS.md @@ -0,0 +1,222 @@ +# Performance Budgets — OmniRoute (2026-06-18) + +**Status**: Authoritative. SLO targets that the 71-pillar audit (L13) +references for the `Perf > 2.00` gate. +**Methodology**: per-endpoint p50/p95/p99 latency budgets, plus a +top-level availability SLO. Budgets are derived from the 3-replica +Caddy + Redis topology (commit `038439fa7`); adjust on infra change. +**Enforcement**: `benches/perf-gate.k6.js` (k6 reference script) — +**not** yet wired into CI; `bench/` already exists in the repo +and the script can be added there in a follow-up PR. +**Re-evaluation cadence**: quarterly, or on any major infra change. + +--- + +## 1. Top-level SLOs + +| SLO | Target | Window | Page on breach | +|---|---|---|---| +| **Availability** (2xx or 4xx for /v1/* and /api/settings/*) | 99.9% | rolling 30 days | on-call P2 | +| **Error budget burn rate** (1xx normalized rate) | < 2x for 1h, < 6x for 5m | 1h / 5m windows | on-call P1 | +| **Aggregate p95 latency** (all /v1/*) | ≤ 1.5 s | rolling 5 min | on-call P2 | +| **Aggregate p99 latency** (all /v1/*) | ≤ 4.0 s | rolling 5 min | on-call P2 | + +**Error budget**: 30-day window = 43.2 minutes of unavailability at +99.9%. Burn rate > 2x is P2; > 6x is P1. + +--- + +## 2. Per-endpoint latency budgets + +All budgets measured **server-side** (Next.js Route Handler entry to +response start, or last byte for streaming). Stream endpoints are +measured to time-of-first-byte (TTFB) since the body is incremental. + +### 2.1 Inference endpoints (the hot path) + +| Endpoint | Method | p50 | p95 | p99 | Notes | +|---|---|---|---|---|---| +| `/v1/responses` (non-stream) | POST | 800 ms | 1.8 s | 3.5 s | Includes translator + provider roundtrip | +| `/v1/responses` (stream) | POST (TTFB) | 350 ms | 900 ms | 1.8 s | TTFB only; total duration unbounded | +| `/v1/relay/chat/completions` (non-stream) | POST | 1.0 s | 2.2 s | 4.0 s | Includes per-(token,IP) rate-limit check | +| `/v1/relay/chat/completions` (stream) | POST (TTFB) | 400 ms | 1.0 s | 2.0 s | | +| `/v1/embeddings` | POST | 300 ms | 700 ms | 1.4 s | Pure provider roundtrip; cheap | +| `/v1/rerank` | POST | 600 ms | 1.4 s | 2.8 s | | +| `/v1/moderations` | POST | 250 ms | 600 ms | 1.2 s | Lightweight classification | +| `/v1/audio/speech` | POST | 1.2 s | 3.0 s | 6.0 s | Audio synthesis is slow; budget reflects that | +| `/v1/audio/transcriptions` | POST | 2.0 s | 5.0 s | 10.0 s | STT is bounded by audio duration + model size | +| `/v1/images/generations` | POST | 4.0 s | 8.0 s | 15.0 s | Image gen is async-bound by provider | +| `/v1/videos/generations` | POST (TTFB) | 600 ms | 1.5 s | 3.0 s | Async; client polls `/v1/videos/{id}` | +| `/v1/music/generations` | POST | 3.0 s | 6.0 s | 12.0 s | | + +### 2.2 Files + batches + +| Endpoint | Method | p50 | p95 | p99 | Notes | +|---|---|---|---|---|---| +| `/v1/files` (GET) | GET | 80 ms | 200 ms | 400 ms | Cached list | +| `/v1/files` (POST upload) | POST | 500 ms | 1.2 s | 2.5 s | 25 MB cap; multipart parse | +| `/v1/files/{id}` (GET) | GET | 60 ms | 150 ms | 300 ms | | +| `/v1/files/{id}` (DELETE) | DELETE | 80 ms | 200 ms | 400 ms | | +| `/v1/files/{id}/content` (download) | GET | 100 ms | 300 ms | 600 ms | + per-MB throughput | +| `/v1/batches` (GET) | GET | 150 ms | 400 ms | 800 ms | | +| `/v1/batches` (POST create) | POST | 200 ms | 500 ms | 1.0 s | Validates input file then enqueues | +| `/v1/batches/{id}` (GET) | GET | 100 ms | 300 ms | 600 ms | | +| `/v1/batches/{id}` (DELETE) | DELETE | 100 ms | 300 ms | 600 ms | | +| `/v1/batches/delete-completed` (POST) | POST | 400 ms | 1.0 s | 2.0 s | Mass delete; n rows | + +### 2.3 Agents + +| Endpoint | Method | p50 | p95 | p99 | Notes | +|---|---|---|---|---|---| +| `/v1/agents/health` | GET | 1.5 s | 4.5 s | 5.0 s | 5s per-provider timeout cap; expect 3-provider total | +| `/v1/agents/credentials` | GET | 100 ms | 250 ms | 500 ms | Metadata only; values never returned | +| `/v1/agents/tasks` (GET list) | GET | 150 ms | 400 ms | 800 ms | | +| `/v1/agents/tasks` (POST create) | POST | 250 ms | 600 ms | 1.2 s | Just enqueues; doesn't run agent | +| `/v1/agents/tasks/{id}` (GET) | GET | 100 ms | 300 ms | 600 ms | | +| `/v1/agents/tasks/{id}` (DELETE) | DELETE | 150 ms | 400 ms | 800 ms | | + +### 2.4 Combos / me / providers + +| Endpoint | Method | p50 | p95 | p99 | +|---|---|---|---|---| +| `/v1/combos` | GET | 80 ms | 200 ms | 400 ms | +| `/v1/me/status` | GET | 60 ms | 150 ms | 300 ms | +| `/v1/providers/{provider}/models` | GET | 100 ms | 250 ms | 500 ms | + +### 2.5 Web / search + +| Endpoint | Method | p50 | p95 | p99 | Notes | +|---|---|---|---|---|---| +| `/v1/web/fetch` | POST | 1.5 s | 4.0 s | 8.0 s | 10s timeout cap; recurse depth 3 | +| `/v1/search` | POST | 800 ms | 2.0 s | 4.0 s | Provider search latency varies | + +### 2.6 VSCode-CLI shim (token-scoped) + +These are the legacy passthrough paths. Budgets are tighter because +they're called frequently by the VSCode-CLI extension in tight loops. + +| Endpoint | Method | p50 | p95 | p99 | +|---|---|---|---|---| +| `/v1/vscode/{token}/v1/chat/completions` | POST | 700 ms | 1.6 s | 3.0 s | +| `/v1/vscode/{token}/v1/models` | GET | 60 ms | 150 ms | 300 ms | +| `/v1/vscode/{token}/combos` | GET | 80 ms | 200 ms | 400 ms | +| `/v1/vscode/{token}/chat/completions` (legacy) | POST | 700 ms | 1.6 s | 3.0 s | +| `/v1/vscode/{token}/models` (legacy) | GET | 60 ms | 150 ms | 300 ms | +| `/v1/vscode/{token}/responses` | POST | 800 ms | 1.8 s | 3.5 s | + +### 2.7 Management / settings + +Management endpoints are operator-only and not part of the hot path. +Budgets are set conservatively; breaches don't page on-call but do +flag in the weekly perf review. + +| Endpoint group | p50 | p95 | p99 | +|---|---|---|---| +| `/api/settings/*` (GET) | 100 ms | 300 ms | 600 ms | +| `/api/settings/*` (POST/PATCH/DELETE) | 200 ms | 500 ms | 1.0 s | +| `/api/keys/*` (CRUD) | 150 ms | 400 ms | 800 ms | +| `/api/quota/*` (CRUD) | 150 ms | 400 ms | 800 ms | +| `/api/monitoring/health` (heavy) | 500 ms | 1.5 s | 3.0 s | + +### 2.8 Public probes + +| Endpoint | Method | p50 | p95 | p99 | +|---|---|---|---|---| +| `/api/health/ping` | GET | 5 ms | 20 ms | 50 ms | +| `/api/version` | GET | 5 ms | 20 ms | 50 ms | +| `/api/docs` | GET | 20 ms | 80 ms | 200 ms (HTML shell, no provider call) | + +--- + +## 3. Throughput targets + +| Tier | Per-replica RPS | Cluster RPS (3 replicas) | Notes | +|---|---|---|---| +| Inference (non-stream) | 50 RPS | 150 RPS | Bounded by provider quota + translator CPU | +| Inference (stream) | 25 concurrent streams | 75 streams | Bounded by Node event-loop + memory | +| Embeddings | 200 RPS | 600 RPS | Cheap | +| Files (upload) | 10 RPS | 30 RPS | Multipart parse + DB write | +| Files (download) | 100 RPS | 300 RPS | Static-content via Next.js | +| Combos / me / providers | 500 RPS | 1,500 RPS | Cached | +| WebSocket | 100 concurrent connections | 300 | Per-IP cap 5 | + +**Cluster ceiling** (all endpoints combined, sustained): ~1,000 RPS +before p95 latency begins to climb. Scale horizontally beyond that +by adding replicas; the Caddy LB is stateless. + +--- + +## 4. Resource budgets + +| Resource | Per-replica cap | Notes | +|---|---|---| +| RSS memory | 1.5 GB | Spikes during audio/video gen; expect brief 2 GB | +| Event-loop lag (p99) | 50 ms | Alert via `clinic doctor` regression | +| Heap retained | 800 MB | Old-gen GC tuning in `node --max-old-space-size` | +| File descriptors | 2,000 | `ulimit -n 4096` recommended at host | +| DB connections (sql.js) | 1 per replica | sql.js is in-process; no pool needed | +| Redis connections | 20 per replica | Pooled; idle reaped at 5 min | + +--- + +## 5. Cold-start budget + +Next.js App Router cold-start on a fresh container: + +| Phase | Budget | +|---|---| +| Container start → HTTP listening | ≤ 800 ms | +| First request TTFB (warm) | ≤ 200 ms | +| Translator registry bootstrap | ≤ 500 ms (one-time, first /v1/responses) | + +**Measurement script**: `bin/cold-start-bench.sh` (not yet committed; +`bin/` is the canonical scripts dir). + +--- + +## 6. Regression gate (k6 reference) + +The `benches/perf-gate.k6.js` script asserts the SLOs above. Not yet +wired into CI; the JSON output is the contract for the next infra PR. + +```javascript +// benches/perf-gate.k6.js — pseudo-code; not yet committed +import http from 'k6/http'; +import { check, Trend } from 'k6'; + +const responsesTTFB = new Trend('v1_responses_ttfb', true); + +export const options = { + scenarios: { + smoke: { + executor: 'constant-vus', + vus: 10, + duration: '1m', + }, + }, + thresholds: { + 'http_req_duration{endpoint:v1_responses}': ['p(95)<1800', 'p(99)<3500'], + 'http_req_failed': ['rate<0.01'], + 'v1_responses_ttfb': ['p(95)<900'], + }, +}; + +export default function () { + const res = http.post(`${__ENV.BASE_URL}/api/v1/responses`, JSON.stringify({ + model: 'gpt-4o-mini', + input: 'ping', + }), { headers: { 'Authorization': `Bearer ${__ENV.API_KEY}` }}); + check(res, { 'status is 200': (r) => r.status === 200 }); + responsesTTFB.add(res.timings.waiting); +} +``` + +--- + +## 7. Review log + +| Date | Reviewer | Change | +|---|---|---| +| 2026-06-18 | security-circle lead | Initial per-endpoint budgets derived from 3-replica Caddy + Redis topology | +| 2026-07-18 (planned) | observability-circle | Wire `benches/perf-gate.k6.js` into CI; gate on p95 + p99 breach | +| 2026-09-18 (planned) | observability-circle | Quarterly review; adjust after real-traffic baseline data | From 9a5627a4a638e9968470eab138cfdd821472d5ac Mon Sep 17 00:00:00 2001 From: Makcim Ivanov Date: Wed, 24 Jun 2026 04:51:54 +0300 Subject: [PATCH 048/120] fix(proxy): fan out direct dispatcher streams (#4803) Integrated into release/v3.8.36 --- open-sse/utils/proxyDispatcher.ts | 82 +++++++++++++++++-- .../direct-dispatcher-pipelining-4580.test.ts | 33 +++++++- 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index a557e058ea04..e7871722530f 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -43,6 +43,58 @@ type ProxyConfigObject = { family?: string; }; +/** + * Direct upstream fan-out dispatcher. + * + * A single Undici Agent configured with `connections > 1` should be enough in + * theory, but real Codex `/backend-api/codex/responses` streams on Node 24 have + * still been observed queuing every subsequent same-origin request until the + * previous stream emits trailers. Using several one-connection Agents gives + * each long SSE stream an independent pool/client and prevents one stream from + * monopolizing the effective queue while keeping pipelining disabled. + */ +class RoundRobinDispatcher { + private readonly dispatchers: Dispatcher[]; + private nextIndex = 0; + + constructor(dispatchers: Dispatcher[]) { + this.dispatchers = dispatchers; + } + + dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean { + const dispatcher = this.dispatchers[this.nextIndex % this.dispatchers.length]; + this.nextIndex = (this.nextIndex + 1) % this.dispatchers.length; + return dispatcher.dispatch(options, handler); + } + + close(callback?: () => void): Promise | void { + const done = Promise.all(this.dispatchers.map((dispatcher) => dispatcher.close())).then( + () => undefined + ); + if (callback) { + done.then(callback); + return; + } + return done; + } + + destroy( + errorOrCallback?: Error | null | (() => void), + callback?: () => void + ): Promise | void { + const callbackFn = typeof errorOrCallback === "function" ? errorOrCallback : callback; + const error = typeof errorOrCallback === "function" ? null : (errorOrCallback ?? null); + const done = Promise.all(this.dispatchers.map((dispatcher) => dispatcher.destroy(error))).then( + () => undefined + ); + if (callbackFn) { + done.then(callbackFn); + return; + } + return done; + } +} + function getDispatcherCache(): DispatcherCache { const globalWithCache = globalThis as GlobalWithDispatcherCache; if (!globalWithCache[DISPATCHER_CACHE_KEY]) { @@ -136,12 +188,11 @@ export function getDefaultDispatcherConnectionLimit( function getDefaultDispatcherOptions(env: Record = process.env) { const options = getDispatcherOptions(); - // #4580 — On the direct egress path, undici's default pipelining (1) lets a long - // SSE stream monopolize the single pooled socket per origin, serializing every - // other concurrent request to that same provider. Mirror the proxy fix (#4288): - // disable pipelining and keep several connections available. Unlike the proxy - // path we KEEP keep-alive — the 1ms TTL there is a cheap-proxy-socket workaround, - // not needed (and harmful to perf) for direct connections. + // #4580 — On the direct egress path, undici's default pipelining (1) let a long + // SSE stream monopolize the single pooled socket per origin. Keep the public + // connection-limit option here, but getDefaultDispatcher() fans it out across + // independent one-connection Agents; in production traces, one multi-connection + // Agent could still queue same-origin Codex streams behind prior trailers. return { ...options, connections: getDefaultDispatcherConnectionLimit(env), @@ -149,10 +200,23 @@ function getDefaultDispatcherOptions(env: Record = p }; } +function createRoundRobinDirectDispatcher(connectionLimit: number): Dispatcher { + const baseOptions = getDispatcherOptions(); + const perAgentOptions = { + ...baseOptions, + connections: 1, + pipelining: 0, + }; + const dispatchers = Array.from({ length: connectionLimit }, () => new Agent(perAgentOptions)); + return new RoundRobinDispatcher(dispatchers) as unknown as Dispatcher; +} + export function getDefaultDispatcher(): Dispatcher { const globalWithCache = globalThis as GlobalWithDispatcherCache; if (!globalWithCache[DEFAULT_DISPATCHER_KEY]) { - globalWithCache[DEFAULT_DISPATCHER_KEY] = new Agent(getDefaultDispatcherOptions()); + globalWithCache[DEFAULT_DISPATCHER_KEY] = createRoundRobinDirectDispatcher( + getDefaultDispatcherConnectionLimit() + ); } return globalWithCache[DEFAULT_DISPATCHER_KEY]; } @@ -415,6 +479,10 @@ export function __getDefaultDispatcherOptionsForTest( return getDefaultDispatcherOptions(env); } +export function __createRoundRobinDispatcherForTest(dispatchers: Dispatcher[]): Dispatcher { + return new RoundRobinDispatcher(dispatchers) as unknown as Dispatcher; +} + export function createProxyDispatcher(proxyUrl: string): Dispatcher { const normalizedUrl = normalizeProxyUrl(proxyUrl, "proxy dispatcher"); const dispatcherCache = getDispatcherCache(); diff --git a/tests/unit/direct-dispatcher-pipelining-4580.test.ts b/tests/unit/direct-dispatcher-pipelining-4580.test.ts index a56ed9083148..3f6ae944df57 100644 --- a/tests/unit/direct-dispatcher-pipelining-4580.test.ts +++ b/tests/unit/direct-dispatcher-pipelining-4580.test.ts @@ -1,6 +1,8 @@ import { describe, it, afterEach } from "node:test"; import assert from "node:assert/strict"; +import type { Dispatcher } from "undici"; import { + __createRoundRobinDispatcherForTest, __getDefaultDispatcherOptionsForTest, __getProxyDispatcherOptionsForTest, getDefaultDispatcherConnectionLimit, @@ -39,7 +41,10 @@ describe("#4580 direct dispatcher options", () => { }); it("connection limit honors OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS", () => { - assert.equal(getDefaultDispatcherConnectionLimit({ OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS: "8" }), 8); + assert.equal( + getDefaultDispatcherConnectionLimit({ OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS: "8" }), + 8 + ); }); it("connection limit clamps invalid values to the default", () => { @@ -49,4 +54,30 @@ describe("#4580 direct dispatcher options", () => { ); assert.equal(getDefaultDispatcherConnectionLimit({}), 32); }); + + it("fans out direct requests across independent dispatcher pools", () => { + const calls: number[] = []; + const dispatchers = [0, 1, 2].map( + (index) => + ({ + dispatch() { + calls.push(index); + return true; + }, + close() {}, + destroy() {}, + }) as unknown as Dispatcher + ); + const dispatcher = __createRoundRobinDispatcherForTest(dispatchers); + const dispatchOptions = { + origin: "https://chatgpt.com", + path: "/backend-api/codex/responses", + method: "POST", + } as unknown as Parameters[0]; + const handler = {} as Parameters[1]; + + for (let i = 0; i < 7; i++) dispatcher.dispatch(dispatchOptions, handler); + + assert.deepEqual(calls, [0, 1, 2, 0, 1, 2, 0]); + }); }); From 805519befb8355294e24aa68f809af3d8dff1fab Mon Sep 17 00:00:00 2001 From: Chewji <126886556+Chewji9875@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:52:50 +0700 Subject: [PATCH 049/120] fix(antigravity): exclude standard Gemini rate limit message from quota exhaustion keywords (#4810) Integrated into release/v3.8.36 --- open-sse/services/antigravity429Engine.ts | 1 - tests/unit/antigravity-429-quota-cooldown.test.ts | 11 +++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/open-sse/services/antigravity429Engine.ts b/open-sse/services/antigravity429Engine.ts index 36364a9e6cca..909ed8adff29 100644 --- a/open-sse/services/antigravity429Engine.ts +++ b/open-sse/services/antigravity429Engine.ts @@ -53,7 +53,6 @@ const CREDITS_EXHAUSTED_KEYWORDS = [ "minimumcreditamountforusage", "minimum credit amount for usage", "minimum credit", - "resource has been exhausted", ]; const SHORT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes diff --git a/tests/unit/antigravity-429-quota-cooldown.test.ts b/tests/unit/antigravity-429-quota-cooldown.test.ts index 3791e4ff2a12..f0776c969424 100644 --- a/tests/unit/antigravity-429-quota-cooldown.test.ts +++ b/tests/unit/antigravity-429-quota-cooldown.test.ts @@ -55,6 +55,17 @@ test("classify429: AG 'Individual quota reached' message → quota_exhausted", ( assert.equal(classify429(msg), "quota_exhausted"); }); +test("classify429: standard Gemini rate limit 'resource has been exhausted' -> rate_limited or unknown, not quota_exhausted", () => { + const msg = + "RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. queries per minute limit was reached)."; + const result = classify429(msg); + assert.notEqual( + result, + "quota_exhausted", + "RESOURCE_EXHAUSTED rate limit should not be classified as quota_exhausted" + ); +}); + // ── DB persistence (the missing wire — Bug #2) ─────────────────────────────── test("markConnectionQuotaExhausted persists 24h cooldown; isConnectionRateLimited returns true", async () => { From e1ffc8e85018d639b0344389675d9bcc997b00c2 Mon Sep 17 00:00:00 2001 From: Anton <39598727+NomenAK@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:53:13 +0200 Subject: [PATCH 050/120] fix(sse): skip third-party tool-name cloak for Anthropic server tools (#4808) Integrated into release/v3.8.36 --- open-sse/services/claudeCodeToolRemapper.ts | 27 ++++++++++++++++ tests/unit/claude-oauth-tool-cloak.test.ts | 35 +++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/open-sse/services/claudeCodeToolRemapper.ts b/open-sse/services/claudeCodeToolRemapper.ts index 16c97a7b3b71..9ab697a1009b 100644 --- a/open-sse/services/claudeCodeToolRemapper.ts +++ b/open-sse/services/claudeCodeToolRemapper.ts @@ -197,6 +197,28 @@ export function needsThirdPartyCloak(name: string): boolean { return /[a-z]/.test(name.charAt(0)) || name.includes("_") || name.includes("-"); } +/** + * Anthropic server-side tool types whose `name` is a reserved literal that the + * Messages API validates exactly (e.g. type `web_search_20250305` REQUIRES + * `name: "web_search"`). These look like third-party harness tools to the name + * cloak (`web_search` has a `_`, fails the PascalCase test) so without this + * guard the cloak rewrites the name to `WebSearch` and Anthropic 400s with + * `tools.N.web_search_20250305.name: Input should be 'web_search'`. + * + * Detection mirrors the codebase's existing convention: a versioned built-in + * tool type carries an 8-digit date suffix (`web_search_20250305`, + * `code_execution_20250522`, `bash_20250124`, …) — see + * `stripVersionedToolModelPrefix` in executors/base.ts. The non-versioned + * aliases (`web_search`, `web_search_preview`) are covered explicitly. + */ +const VERSIONED_SERVER_TOOL_TYPE = /^[a-z][a-z0-9_]*_\d{8}$/; +const NON_VERSIONED_SERVER_TOOL_TYPES = new Set(["web_search", "web_search_preview"]); + +export function isAnthropicServerToolType(type: unknown): boolean { + if (typeof type !== "string" || type.length === 0) return false; + return VERSIONED_SERVER_TOOL_TYPE.test(type) || NON_VERSIONED_SERVER_TOOL_TYPES.has(type); +} + export interface CloakOptions { /** * Names matching this predicate are left untouched, so a caller that owns a @@ -265,6 +287,11 @@ export function cloakThirdPartyToolNames( // not corrupt an input body that may be logged or replayed on fallback). if (Array.isArray(tools)) { body.tools = tools.map((tool) => { + // Never rewrite the reserved name of an Anthropic server-side tool — its + // `type` (web_search_20250305, …) binds the API to an exact `name`. + if (tool && isAnthropicServerToolType(tool.type)) { + return tool; + } if (tool && typeof tool.name === "string" && shouldCloak(tool.name)) { return { ...tool, name: aliasFor(tool.name) }; } diff --git a/tests/unit/claude-oauth-tool-cloak.test.ts b/tests/unit/claude-oauth-tool-cloak.test.ts index 5d8caadd1fa5..2fdb3c71b549 100644 --- a/tests/unit/claude-oauth-tool-cloak.test.ts +++ b/tests/unit/claude-oauth-tool-cloak.test.ts @@ -13,6 +13,7 @@ import assert from "node:assert/strict"; import { cloakThirdPartyToolNames, needsThirdPartyCloak, + isAnthropicServerToolType, } from "../../open-sse/services/claudeCodeToolRemapper.ts"; import { sanitizeClaudeToolSchema, @@ -107,6 +108,40 @@ describe("cloakThirdPartyToolNames", () => { assert.equal(needsThirdPartyCloak("read_file"), true); assert.equal(needsThirdPartyCloak("mixture_of_agents"), true); }); + + it("preserves the reserved name of a versioned Anthropic server tool", () => { + const body: AnyRecord = { + tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 5 }], + }; + cloakThirdPartyToolNames(body); + // Anthropic requires tools.N.web_search_20250305.name === "web_search". + assert.equal((body.tools as AnyRecord[])[0].name, "web_search"); + // No reverse-map entry needed because nothing was cloaked. + assert.equal((body._toolNameMap as Map | undefined)?.has("WebSearch") ?? false, false); + }); + + it("still cloaks a genuine third-party tool sitting next to a server tool", () => { + const body: AnyRecord = { + tools: [ + { type: "web_search_20250305", name: "web_search" }, + { name: "mixture_of_agents" }, + ], + }; + cloakThirdPartyToolNames(body); + assert.equal((body.tools as AnyRecord[])[0].name, "web_search"); + assert.equal((body.tools as AnyRecord[])[1].name, "MixtureOfAgents"); + }); + + it("isAnthropicServerToolType detects versioned + non-versioned server tools", () => { + assert.equal(isAnthropicServerToolType("web_search_20250305"), true); + assert.equal(isAnthropicServerToolType("code_execution_20250522"), true); + assert.equal(isAnthropicServerToolType("web_search"), true); + assert.equal(isAnthropicServerToolType("web_search_preview"), true); + // Not server tools — must remain cloakable. + assert.equal(isAnthropicServerToolType("mixture_of_agents"), false); + assert.equal(isAnthropicServerToolType("Bash"), false); + assert.equal(isAnthropicServerToolType(undefined), false); + }); }); describe("sanitizeClaudeToolSchemas — boolean schema preservation", () => { From 604654cc3a004b0b09157192cbd05af5769531e6 Mon Sep 17 00:00:00 2001 From: Demiurge The Single Date: Wed, 24 Jun 2026 04:54:15 +0300 Subject: [PATCH 051/120] fix(install): make transformers optional for CUDA-host installs (#4807) Integrated into release/v3.8.36 --- docs/compression/COMPRESSION_ENGINES.md | 11 +- docs/ops/RELEASE_CHECKLIST.md | 17 +- package-lock.json | 149 ++++++++++++------ package.json | 2 +- .../optional-transformers-dependency.test.ts | 45 ++++++ 5 files changed, 161 insertions(+), 63 deletions(-) create mode 100644 tests/unit/build/optional-transformers-dependency.test.ts diff --git a/docs/compression/COMPRESSION_ENGINES.md b/docs/compression/COMPRESSION_ENGINES.md index 616c8c68a8de..4fadec80b4a4 100644 --- a/docs/compression/COMPRESSION_ENGINES.md +++ b/docs/compression/COMPRESSION_ENGINES.md @@ -116,7 +116,7 @@ override points it at a local copy instead (offline / air-gapped installs). ### Optional dependencies & on-demand install -The LLMLingua runtime stack is **optional**. Three packages are declared as +The prunable LLMLingua runtime peer stack is **optional**. Three packages are declared as `optionalDependencies` in `package.json` and kept **external** by the production build (`scripts/build/prepublish.ts` does not bundle them): @@ -126,9 +126,12 @@ The LLMLingua runtime stack is **optional**. Three packages are declared as | `@tensorflow/tfjs` | `4.22.0` | Heaviest dep — dominates the ~800 MB footprint | | `js-tiktoken` | `^1.0.20` | Tokenizer | -`@huggingface/transformers` is pinned at `3.5.2` as a **regular** dependency (shared with -the local embeddings path), so it always ships — only the three packages above are -prunable. A standard `npm install` (dev) installs them automatically. +`@huggingface/transformers` is pinned at `3.5.2` as an **optional** dependency (shared with +the local embeddings path and also traced into the standalone bundle). Keeping it optional prevents +`onnxruntime-node` CUDA provider postinstall failures on CUDA 11 hosts from aborting the whole +OmniRoute install; when the optional stack is absent, LLMLingua still fail-opens. Only the three +packages above are prunable SLM peers. A standard `npm install` (dev) installs the optional stack +automatically unless optional dependencies are omitted. **Why on-demand:** the npm-published package, the standalone bundle, and the Docker image ship **without** these deps to stay slim. When they are absent, the worker's dependency diff --git a/docs/ops/RELEASE_CHECKLIST.md b/docs/ops/RELEASE_CHECKLIST.md index fffe18276921..29e0688e6bde 100644 --- a/docs/ops/RELEASE_CHECKLIST.md +++ b/docs/ops/RELEASE_CHECKLIST.md @@ -269,14 +269,15 @@ Before shipping any v3.8.x release, verify these additional items: - [ ] Update path keeps optional deps: `omniroute update --apply` and the auto-updater run `npm install -g … --include=optional` so `optionalDependencies` (better-sqlite3, keytar, tls-client, and the llmlingua SLM stack: `@atjsh/llmlingua-2`, - `@tensorflow/tfjs`, `js-tiktoken`) survive an update. The ultra `modelPath` SLM tier - additionally needs `@huggingface/transformers@3.5.2` (pinned — llmlingua-2 uses the 3.x - tokenizer API) and the tinybert model, auto-downloaded to `${DATA_DIR}/models/llmlingua` - on first use. Postinstall (`scripts/build/colocateOptionals.mjs`) then co-locates the SLM - optional closure into `dist/node_modules` so the worker resolves a SINGLE - `@huggingface/transformers` 3.5.2 instance — the standalone trace bundles only transformers, - not the dynamically-imported optionals, so without this the worker would load llmlingua-2 - against the root's transformers and the SLM tier would silently fail-open. + `@huggingface/transformers@3.5.2`, `@tensorflow/tfjs`, `js-tiktoken`) survive an update. + `@huggingface/transformers` stays optional so its `onnxruntime-node` CUDA provider postinstall + cannot abort installation on CUDA 11 hosts. The ultra `modelPath` SLM tier also needs the + tinybert model, auto-downloaded to `${DATA_DIR}/models/llmlingua` on first use. Postinstall + (`scripts/build/colocateOptionals.mjs`) then co-locates the SLM optional closure into + `dist/node_modules` so the worker resolves a SINGLE `@huggingface/transformers` 3.5.2 + optional instance — the standalone trace bundles only transformers, not the dynamically-imported + optionals, so without this the worker would load llmlingua-2 against the root's transformers + and the SLM tier would silently fail-open. - [ ] `omniroute status` works with no `.env` (CLI token path, loopback only) - [ ] `curl http://localhost:20128/api/shutdown` returns 401 (always-protected route) - [ ] `curl -H "host: evil.com" http://localhost:20128/api/mcp/sse` returns 401 (loopback guard) diff --git a/package-lock.json b/package-lock.json index 4769f03de36f..7d175ad38b34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@huggingface/transformers": "3.5.2", "@lobehub/icons": "^5.8.0", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", @@ -140,6 +139,7 @@ }, "optionalDependencies": { "@atjsh/llmlingua-2": "2.0.3", + "@huggingface/transformers": "3.5.2", "@tensorflow/tfjs": "4.22.0", "better-sqlite3": "^12.10.0", "js-tiktoken": "^1.0.20", @@ -2578,7 +2578,8 @@ "license": "MIT", "engines": { "node": ">=18" - } + }, + "optional": true }, "node_modules/@huggingface/transformers": { "version": "3.5.2", @@ -2590,7 +2591,8 @@ "onnxruntime-node": "1.21.0", "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", "sharp": "^0.34.1" - } + }, + "optional": true }, "node_modules/@humanfs/core": { "version": "0.19.1", @@ -2668,7 +2670,8 @@ "license": "MIT", "engines": { "node": ">=18" - } + }, + "optional": true }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.34.5", @@ -3565,7 +3568,8 @@ }, "engines": { "node": ">=18.0.0" - } + }, + "devOptional": true }, "node_modules/@isaacs/string-locale-compare": { "version": "1.1.0", @@ -6244,25 +6248,29 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/codegen": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/fetch": { "version": "1.1.1", @@ -6271,31 +6279,36 @@ "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.1" - } + }, + "optional": true }, "node_modules/@protobufjs/float": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@protobufjs/utf8": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/@radix-ui/number": { "version": "1.1.2", @@ -9416,7 +9429,8 @@ "license": "MIT", "dependencies": { "undici-types": "~8.3.0" - } + }, + "devOptional": true }, "node_modules/@types/node-fetch": { "version": "2.6.13", @@ -11169,7 +11183,8 @@ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/bottleneck": { "version": "2.19.5", @@ -13295,7 +13310,8 @@ }, "funding": { "url": "https://github.com/sponsors/ljharb" - } + }, + "devOptional": true }, "node_modules/define-lazy-prop": { "version": "3.0.0", @@ -13324,7 +13340,8 @@ }, "funding": { "url": "https://github.com/sponsors/ljharb" - } + }, + "devOptional": true }, "node_modules/delaunator": { "version": "5.1.0", @@ -13395,7 +13412,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/detect-node-es": { "version": "1.1.0", @@ -13973,7 +13991,8 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/esast-util-from-estree": { "version": "2.0.0", @@ -15306,7 +15325,8 @@ "version": "25.9.23", "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "optional": true }, "node_modules/flatted": { "version": "3.4.2", @@ -16056,7 +16076,8 @@ }, "engines": { "node": ">=10.0" - } + }, + "optional": true }, "node_modules/global-agent/node_modules/semver": { "version": "7.8.4", @@ -16068,7 +16089,8 @@ }, "engines": { "node": ">=10" - } + }, + "optional": true }, "node_modules/global-directory": { "version": "4.0.1", @@ -16121,7 +16143,8 @@ }, "funding": { "url": "https://github.com/sponsors/ljharb" - } + }, + "devOptional": true }, "node_modules/gopd": { "version": "1.2.0", @@ -16145,7 +16168,8 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/hachure-fill": { "version": "0.5.2", @@ -16185,7 +16209,8 @@ }, "funding": { "url": "https://github.com/sponsors/ljharb" - } + }, + "devOptional": true }, "node_modules/has-proto": { "version": "1.2.0", @@ -18265,7 +18290,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/json5": { "version": "2.2.3", @@ -19788,7 +19814,8 @@ }, "engines": { "node": ">=10" - } + }, + "optional": true }, "node_modules/material-symbols": { "version": "0.45.2", @@ -21037,7 +21064,8 @@ "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" - } + }, + "devOptional": true }, "node_modules/minipass-collect": { "version": "2.0.1", @@ -21159,7 +21187,8 @@ }, "engines": { "node": ">= 18" - } + }, + "devOptional": true }, "node_modules/mkdirp": { "version": "1.0.4", @@ -22053,7 +22082,8 @@ "license": "MIT", "engines": { "node": ">= 0.4" - } + }, + "devOptional": true }, "node_modules/object.assign": { "version": "4.1.7", @@ -22222,7 +22252,8 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/onnxruntime-node": { "version": "1.21.0", @@ -22239,7 +22270,8 @@ "global-agent": "^3.0.0", "onnxruntime-common": "1.21.0", "tar": "^7.0.1" - } + }, + "optional": true }, "node_modules/onnxruntime-web": { "version": "1.22.0-dev.20250409-89f8206ba4", @@ -22253,19 +22285,22 @@ "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" - } + }, + "optional": true }, "node_modules/onnxruntime-web/node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "optional": true }, "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { "version": "1.22.0-dev.20250409-89f8206ba4", "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/open": { "version": "11.0.0", @@ -22944,7 +22979,8 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/playwright": { "version": "1.61.0", @@ -23291,13 +23327,15 @@ }, "engines": { "node": ">=12.0.0" - } + }, + "optional": true }, "node_modules/protobufjs/node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "optional": true }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -24427,13 +24465,15 @@ }, "engines": { "node": ">=8.0" - } + }, + "optional": true }, "node_modules/roarr/node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/robust-predicates": { "version": "3.0.3", @@ -24750,7 +24790,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/send": { "version": "1.2.1", @@ -24791,7 +24832,8 @@ }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "optional": true }, "node_modules/serialize-error/node_modules/type-fest": { "version": "0.13.1", @@ -24803,7 +24845,8 @@ }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "optional": true }, "node_modules/serve-static": { "version": "2.2.1", @@ -24921,7 +24964,8 @@ "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" - } + }, + "optional": true }, "node_modules/sharp/node_modules/semver": { "version": "7.7.4", @@ -24933,7 +24977,8 @@ }, "engines": { "node": ">=10" - } + }, + "optional": true }, "node_modules/shebang-command": { "version": "2.0.0", @@ -26174,7 +26219,8 @@ }, "engines": { "node": ">=18" - } + }, + "devOptional": true }, "node_modules/tar-fs": { "version": "2.1.4", @@ -26213,7 +26259,8 @@ "license": "BlueOak-1.0.0", "engines": { "node": ">=18" - } + }, + "devOptional": true }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", @@ -26222,7 +26269,8 @@ "license": "BlueOak-1.0.0", "engines": { "node": ">=18" - } + }, + "devOptional": true }, "node_modules/terminal-size": { "version": "4.0.1", @@ -26987,7 +27035,8 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "license": "MIT" + "license": "MIT", + "devOptional": true }, "node_modules/unicode-emoji-modifier-base": { "version": "1.0.0", diff --git a/package.json b/package.json index a9960fd77443..916d5184c68e 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@huggingface/transformers": "3.5.2", "@lobehub/icons": "^5.8.0", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", @@ -272,6 +271,7 @@ }, "optionalDependencies": { "@atjsh/llmlingua-2": "2.0.3", + "@huggingface/transformers": "3.5.2", "@tensorflow/tfjs": "4.22.0", "better-sqlite3": "^12.10.0", "js-tiktoken": "^1.0.20", diff --git a/tests/unit/build/optional-transformers-dependency.test.ts b/tests/unit/build/optional-transformers-dependency.test.ts new file mode 100644 index 000000000000..7ebd121e0746 --- /dev/null +++ b/tests/unit/build/optional-transformers-dependency.test.ts @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const repoRoot = process.cwd(); + +function readJson>(relPath: string): T { + return JSON.parse(readFileSync(join(repoRoot, relPath), "utf8")) as T; +} + +test("@huggingface/transformers is optional so onnxruntime CUDA install failures cannot abort OmniRoute install", () => { + const pkg = readJson<{ + dependencies?: Record; + optionalDependencies?: Record; + }>("package.json"); + + assert.equal( + pkg.dependencies?.["@huggingface/transformers"], + undefined, + "transformers must not be a regular dependency because it pulls onnxruntime-node install scripts" + ); + assert.equal(pkg.optionalDependencies?.["@huggingface/transformers"], "3.5.2"); +}); + +test("package-lock marks transformers and its onnxruntime runtime as optional", () => { + const lock = readJson<{ + packages: Record; optionalDependencies?: Record }>; + }>("package-lock.json"); + + assert.equal( + lock.packages[""]?.dependencies?.["@huggingface/transformers"], + undefined, + "root lock dependencies must not keep transformers as mandatory" + ); + assert.equal(lock.packages[""]?.optionalDependencies?.["@huggingface/transformers"], "3.5.2"); + + for (const packagePath of [ + "node_modules/@huggingface/transformers", + "node_modules/onnxruntime-node", + "node_modules/onnxruntime-common", + ]) { + assert.equal(lock.packages[packagePath]?.optional, true, `${packagePath} should be optional`); + } +}); From e99e2887bf4326a573c4c0c12746ebb7de77e4e7 Mon Sep 17 00:00:00 2001 From: Chewji <126886556+Chewji9875@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:56:14 +0700 Subject: [PATCH 052/120] fix(combo): propagate selected connection ID to fallback error responses for correct model lockout (#4809) Integrated into release/v3.8.36 --- config/quality/file-size-baseline.json | 2 +- src/lib/resilience/modelLockoutSettings.ts | 18 +-- src/sse/handlers/chat.ts | 7 +- .../rate-limit-queue-timeout-lockout.test.ts | 140 ++++++++++++++++++ 4 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 tests/unit/rate-limit-queue-timeout-lockout.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 025383328473..3b21d5761c3e 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -217,7 +217,7 @@ "src/shared/constants/sidebarVisibility.ts": 1100, "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2523, - "src/sse/handlers/chat.ts": 1516, + "src/sse/handlers/chat.ts": 1521, "src/sse/services/auth.ts": 2289 }, "testCap": 800, diff --git a/src/lib/resilience/modelLockoutSettings.ts b/src/lib/resilience/modelLockoutSettings.ts index 463881e28b3a..39e961e724ef 100644 --- a/src/lib/resilience/modelLockoutSettings.ts +++ b/src/lib/resilience/modelLockoutSettings.ts @@ -30,11 +30,7 @@ function toInteger( const min = options.min ?? 0; const max = options.max ?? Number.MAX_SAFE_INTEGER; const parsed = - typeof value === "number" - ? value - : typeof value === "string" - ? Number(value) - : fallback; + typeof value === "number" ? value : typeof value === "string" ? Number(value) : fallback; return Number.isFinite(parsed) ? Math.max(min, Math.min(max, Math.trunc(parsed))) : fallback; } @@ -65,10 +61,14 @@ export function resolveModelLockoutSettings( process.execArgv.includes("--test") || process.argv.some((arg) => typeof arg === "string" && arg.includes("test")); - const baseCooldownMs = toInteger(raw.baseCooldownMs, DEFAULT_MODEL_LOCKOUT_SETTINGS.baseCooldownMs, { - min: isTest ? 0 : 5_000, - max: 600_000, - }); + const baseCooldownMs = toInteger( + raw.baseCooldownMs, + DEFAULT_MODEL_LOCKOUT_SETTINGS.baseCooldownMs, + { + min: isTest ? 0 : 5_000, + max: 600_000, + } + ); const maxCooldownMs = Math.max( toInteger(raw.maxCooldownMs, DEFAULT_MODEL_LOCKOUT_SETTINGS.maxCooldownMs, { min: isTest ? 0 : 5_000, diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 802734ef0dc7..58f732adc303 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -1055,7 +1055,7 @@ async function handleSingleModelChat( breaker._onFailure(); } - return handleNoCredentials( + const noCredsRes = handleNoCredentials( credentials, excludedConnectionIds.size > 0 ? Array.from(excludedConnectionIds)[0] : null, provider, @@ -1063,6 +1063,11 @@ async function handleSingleModelChat( lastError, lastStatus ); + const lastFailedConnectionId = + excludedConnectionIds.size > 0 + ? Array.from(excludedConnectionIds)[excludedConnectionIds.size - 1] + : null; + return withSelectedConnectionHeader(noCredsRes, lastFailedConnectionId); } const accountId = credentials.connectionId.slice(0, 8); diff --git a/tests/unit/rate-limit-queue-timeout-lockout.test.ts b/tests/unit/rate-limit-queue-timeout-lockout.test.ts new file mode 100644 index 000000000000..0fb874cc7f5f --- /dev/null +++ b/tests/unit/rate-limit-queue-timeout-lockout.test.ts @@ -0,0 +1,140 @@ +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(), "omr-rl-queue-timeout-lockout-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-rl-lockout-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const { getModelLockoutInfo, isModelLocked } = + await import("../../open-sse/services/accountFallback.ts"); + +function createLog() { + return { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + }; +} + +async function seedConnection(provider: string, overrides: any = {}): Promise { + return providersDb.createProviderConnection({ + provider, + authType: "apikey", + name: overrides.name || `${provider}-${Math.random().toString(16).slice(2, 8)}`, + apiKey: overrides.apiKey || `sk-test-${Math.random().toString(16).slice(2, 8)}`, + isActive: true, + testStatus: "active", + rateLimitedUntil: null, + backoffLevel: overrides.backoffLevel || 0, + providerSpecificData: overrides.providerSpecificData || {}, + }); +} + +function errorResponseWithoutConnectionId(status: number) { + return new Response(JSON.stringify({ error: { message: "Local queue timeout" } }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function errorResponseWithConnectionId(status: number, connectionId: string) { + return new Response(JSON.stringify({ error: { message: "Local queue timeout" } }), { + status, + headers: { + "content-type": "application/json", + "X-OmniRoute-Selected-Connection-Id": connectionId, + }, + }); +} + +test.afterEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("RATE_LIMIT_QUEUE_TIMEOUT lockout behaves correctly depending on connection ID header", async () => { + const provider = "openai"; + const model = "gpt-4"; + + // Seed the connection first! + const connection = await seedConnection(provider); + const connectionId = connection.id; + + const customSettings = { + modelLockout: { + enabled: true, + errorCodes: [502, 520], + baseCooldownMs: 3000, + maxCooldownMs: 5000, + maxBackoffSteps: 10, + useExponentialBackoff: true, + }, + }; + + const logs = createLog(); + + // Scenario 1: Response lacks connection ID header (Current buggy behavior) + await handleComboChat({ + body: {}, + combo: { + name: "test-combo", + strategy: "priority", + models: [`${provider}/${model}`], + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + }, + handleSingleModel: async () => { + return errorResponseWithoutConnectionId(502); + }, + isModelAvailable: async () => true, + log: logs as any, + settings: customSettings, + allCombos: null, + }); + + // Verify that the model is NOT locked for the actual connection connectionId + const lockedForConnBuggy = isModelLocked(provider, connectionId, model); + assert.equal( + lockedForConnBuggy, + false, + "Model should not be locked for actual connection when header is missing" + ); + + // Verify that it got locked under the empty string connectionId "" + const lockedForEmptyBuggy = isModelLocked(provider, "", model); + assert.equal( + lockedForEmptyBuggy, + true, + "Model is incorrectly locked under empty string connectionId when header is missing" + ); + + const { clearAllModelLockouts } = await import("../../open-sse/services/accountFallback.ts"); + clearAllModelLockouts(); + + await handleComboChat({ + body: {}, + combo: { + name: "test-combo", + strategy: "priority", + models: [`${provider}/${model}`], + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + }, + handleSingleModel: async () => { + return errorResponseWithConnectionId(502, connectionId); + }, + isModelAvailable: async () => true, + log: logs as any, + settings: customSettings, + allCombos: null, + }); + + // Verify that the model IS locked for the actual connection + const lockedForConnFixed = isModelLocked(provider, connectionId, model); + assert.equal(lockedForConnFixed, true, "Model should be locked for the correct connection ID"); +}); From 459ffcf2c09d6154f3f9ea83e7260fdad2626501 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:57:26 -0400 Subject: [PATCH 053/120] fix db storage tuning settings (#4834) Integrated into release/v3.8.36 --- CHANGELOG.md | 1 + docs/ops/DATABASE_GUIDE.md | 5 +- scripts/check/check-db-rules.mjs | 1 + .../settings/components/SystemStorageTab.tsx | 8 +- src/lib/db/core.ts | 84 ++---- src/lib/db/databaseSettings.ts | 18 +- .../db/migrations/046_database_settings.sql | 2 +- .../104_normalize_database_cache_size.sql | 10 + src/lib/db/optimizationSettings.ts | 284 ++++++++++++++++++ src/shared/validation/settingsSchemas.ts | 2 +- src/types/databaseSettings.ts | 2 +- .../check-db-rules-classification.test.ts | 3 +- .../database-settings-maintenance.test.ts | 75 +++++ 13 files changed, 420 insertions(+), 75 deletions(-) create mode 100644 src/lib/db/migrations/104_normalize_database_cache_size.sql create mode 100644 src/lib/db/optimizationSettings.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d463b37a171..9c8d40458aed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ _In development — bullets added per PR; finalized at release._ - **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). - **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). - **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Storage SQLite tuning**: `Cache Size` is now a positive KiB setting (for example, `16384`) that applies to SQLite as `PRAGMA cache_size = -16384`; Page Size and Cache Size changes are applied to the live database instead of being persisted only in the settings table. - **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). - **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). diff --git a/docs/ops/DATABASE_GUIDE.md b/docs/ops/DATABASE_GUIDE.md index d2262a47ddb2..b681e30ebf84 100644 --- a/docs/ops/DATABASE_GUIDE.md +++ b/docs/ops/DATABASE_GUIDE.md @@ -40,9 +40,10 @@ For **single-user, single-instance** deployments (the primary OmniRoute use case ```ts // src/lib/db/core.ts db.pragma("journal_mode = WAL"); -db.pragma("busy_timeout = 5000"); +db.pragma("busy_timeout = 2000"); db.pragma("synchronous = NORMAL"); -db.pragma("cache_size = -2048"); +// Settings > System & Storage > Cache Size is applied as KiB. +db.pragma("cache_size = -16384"); ``` WAL allows **concurrent reads** during writes — important for the dashboard, which queries while requests are being recorded. diff --git a/scripts/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs index c310d56c6f7e..3ec8b1e2be28 100644 --- a/scripts/check/check-db-rules.mjs +++ b/scripts/check/check-db-rules.mjs @@ -57,6 +57,7 @@ export const INTENTIONALLY_INTERNAL = new Set([ "migrationRunner", // db-internal: importado por db/core.ts (runMigrations ao inicializar o DB) "notion", // intentionally-internal: settings/notion API route + open-sse/mcp-server/tools/notionTools.ts "obsidian", // intentionally-internal: src/lib/obsidianSync.ts + settings/obsidian route + MCP obsidianTools.ts + "optimizationSettings", // db-internal: imported by db/core.ts for SQLite PRAGMA application helpers that require the live adapter "pluginMetrics", // DEAD? (production): write path não foi conectado ainda (documentado no cabeçalho do módulo); testado por tests/unit/plugins-metrics.test.ts "prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts "providerNodeSelect", // db-internal: importado só por db/providers.ts (selectProviderNodeForConnection — lógica pura de seleção de provider node split do providers.ts, #4421) diff --git a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx index 461e08ca8ea5..f113d140e263 100644 --- a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx @@ -1030,18 +1030,18 @@ export default function SystemStorageTab() { />
- + setDbSettings({ ...dbSettings, optimization: { ...dbSettings.optimization, - cacheSize: parseInt(e.target.value) || -2000, + cacheSize: parseInt(e.target.value) || 16384, }, }) } diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 5ef681615765..4be1e6044dae 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -18,6 +18,15 @@ import { runMigrations } from "./migrationRunner"; import { runDbHealthCheck } from "./healthCheck"; import { resetAllDbModuleState } from "./stateReset"; import { parseStoredPayload } from "../logPayloads"; +import { DEFAULT_DATABASE_SETTINGS, type DatabaseSettings } from "@/types/databaseSettings"; +import { + applyDatabaseOptimizationSettingsForDb, + applyStoredDatabaseOptimizationSettings, + getAutoVacuumModeForDb, + setAutoVacuumForDb, + setCacheSizeForDb, + setPageSizeForDb, +} from "./optimizationSettings"; import { buildArtifactRelativePath, writeCallArtifact, @@ -29,6 +38,7 @@ import { invalidateDbCache } from "./readCache"; type SqliteDatabase = SqliteAdapter; type JsonRecord = Record; type CheckpointMode = "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE"; +type DatabaseOptimizationSettings = DatabaseSettings["optimization"]; type PreservedTableSnapshot = { table: string; rowCount: number; @@ -1340,7 +1350,7 @@ export function getDbInstance(): SqliteDatabase { // contended op can no longer freeze the loop past the host watchdog's 6s liveness probe. db.pragma("busy_timeout = 2000"); db.pragma("synchronous = NORMAL"); - db.pragma("cache_size = -2048"); + db.pragma(`cache_size = -${DEFAULT_DATABASE_SETTINGS.optimization.cacheSize}`); db.exec(SCHEMA_SQL); ensureProviderConnectionsColumns(db); ensureUsageHistoryColumns(db); @@ -1361,6 +1371,8 @@ export function getDbInstance(): SqliteDatabase { runMigrations(db, { isNewDb }); + applyStoredDatabaseOptimizationSettings(db); + offloadLegacyCallLogDetails(db); // Auto-migrate from db.json if exists @@ -1731,44 +1743,16 @@ function migrateFromJson(db: SqliteDatabase, jsonPath: string) { // ──────────────── Auto-Vacuum Management ──────────────── -export function setAutoVacuum(mode: "NONE" | "FULL" | "INCREMENTAL"): void { - const db = getDbInstance(); - - const currentMode = db.pragma("auto_vacuum", { simple: true }) as number; - const modeMap: Record = { - NONE: 0, - FULL: 1, - INCREMENTAL: 2, - }; - - const targetMode = modeMap[mode]; - - if (currentMode === targetMode) { - console.log(`[DB] auto_vacuum already set to ${mode}`); - return; - } - - console.log(`[DB] Changing auto_vacuum from ${currentMode} to ${mode} (${targetMode})`); - - db.pragma(`auto_vacuum = ${targetMode}`); - - db.exec("VACUUM"); +export function applyDatabaseOptimizationSettings(settings: DatabaseOptimizationSettings): void { + applyDatabaseOptimizationSettingsForDb(getDbInstance(), settings, { applyPersistent: true }); +} - const newMode = db.pragma("auto_vacuum", { simple: true }) as number; - console.log(`[DB] auto_vacuum changed to ${newMode}`); +export function setAutoVacuum(mode: "NONE" | "FULL" | "INCREMENTAL"): void { + setAutoVacuumForDb(getDbInstance(), mode); } export function getAutoVacuumMode(): "NONE" | "FULL" | "INCREMENTAL" { - const db = getDbInstance(); - const mode = db.pragma("auto_vacuum", { simple: true }) as number; - - const modeMap: Record = { - 0: "NONE", - 1: "FULL", - 2: "INCREMENTAL", - }; - - return modeMap[mode] || "NONE"; + return getAutoVacuumModeForDb(getDbInstance()); } export function runManualVacuum(): { success: boolean; duration: number; error?: string } { @@ -1790,35 +1774,9 @@ export function runManualVacuum(): { success: boolean; duration: number; error?: } export function setPageSize(pageSize: number): void { - const db = getDbInstance(); - const currentPageSize = db.pragma("page_size", { simple: true }) as number; - - if (currentPageSize === pageSize) { - console.log(`[DB] page_size already set to ${pageSize}`); - return; - } - - console.log(`[DB] Changing page_size from ${currentPageSize} to ${pageSize}`); - db.pragma(`page_size = ${pageSize}`); - db.exec("VACUUM"); - - const newPageSize = db.pragma("page_size", { simple: true }) as number; - console.log(`[DB] page_size changed to ${newPageSize}`); + setPageSizeForDb(getDbInstance(), pageSize); } export function setCacheSize(cacheSizeKb: number): void { - const db = getDbInstance(); - const currentCacheSize = db.pragma("cache_size", { simple: true }) as number; - const targetCacheSize = -cacheSizeKb; - - if (currentCacheSize === targetCacheSize) { - console.log(`[DB] cache_size already set to ${cacheSizeKb}KB`); - return; - } - - console.log(`[DB] Changing cache_size from ${Math.abs(currentCacheSize)}KB to ${cacheSizeKb}KB`); - db.pragma(`cache_size = ${targetCacheSize}`); - - const newCacheSize = db.pragma("cache_size", { simple: true }) as number; - console.log(`[DB] cache_size changed to ${Math.abs(newCacheSize)}KB`); + setCacheSizeForDb(getDbInstance(), cacheSizeKb); } diff --git a/src/lib/db/databaseSettings.ts b/src/lib/db/databaseSettings.ts index 567ffcf5bab8..fa63534b1856 100644 --- a/src/lib/db/databaseSettings.ts +++ b/src/lib/db/databaseSettings.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import { DEFAULT_DATABASE_SETTINGS, type DatabaseSettings } from "@/types/databaseSettings"; import { backupDbFile } from "./backup"; -import { DATA_DIR, SQLITE_FILE, getDbInstance } from "./core"; +import { DATA_DIR, SQLITE_FILE, applyDatabaseOptimizationSettings, getDbInstance } from "./core"; import { invalidateDbCache } from "./readCache"; import { getDatabaseStats } from "./stats"; import { getState as getVacuumSchedulerState, refreshVacuumScheduler } from "./vacuumScheduler"; @@ -95,6 +95,15 @@ function toBooleanSetting(value: unknown): boolean | null { return null; } +function normalizeOptimizationSettings(settings: UserDatabaseSettings) { + const fallback = DEFAULT_DATABASE_SETTINGS.optimization.cacheSize; + const numericCacheSize = Number(settings.optimization.cacheSize); + settings.optimization.cacheSize = + Number.isFinite(numericCacheSize) && numericCacheSize > 0 + ? Math.min(1000000, Math.floor(numericCacheSize)) + : fallback; +} + function readNamespace(namespace: string): Record { const db = getDbInstance(); const rows = db @@ -220,6 +229,7 @@ export function getUserDatabaseSettings(): UserDatabaseSettings { mergeTopLevelSections(settings, mainSettings); mergeDatabaseSettingsNamespace(settings, readNamespace(DATABASE_SETTINGS_NAMESPACE)); mergeRuntimeLogSettings(settings, mainSettings); + normalizeOptimizationSettings(settings); return settings; } @@ -259,6 +269,7 @@ export function updateDatabaseSettings( mergeSectionObject(nextSettings, section, updates[section]); } } + normalizeOptimizationSettings(nextSettings); const db = getDbInstance(); const insert = db.prepare( @@ -289,7 +300,10 @@ export function updateDatabaseSettings( backupDbFile("pre-write"); invalidateDbCache("settings"); - if (optimizationUpdated) refreshVacuumScheduler(); + if (optimizationUpdated) { + applyDatabaseOptimizationSettings(nextSettings.optimization); + refreshVacuumScheduler(); + } return nextSettings; } diff --git a/src/lib/db/migrations/046_database_settings.sql b/src/lib/db/migrations/046_database_settings.sql index 87df12d24143..57fd15c903be 100644 --- a/src/lib/db/migrations/046_database_settings.sql +++ b/src/lib/db/migrations/046_database_settings.sql @@ -40,5 +40,5 @@ INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSetting INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'autoVacuumMode', '"INCREMENTAL"'); INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'scheduledVacuum', '"weekly"'); INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'pageSize', '4096'); -INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'cacheSize', '10000'); +INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'cacheSize', '16384'); INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'mmapSize', '268435456'); diff --git a/src/lib/db/migrations/104_normalize_database_cache_size.sql b/src/lib/db/migrations/104_normalize_database_cache_size.sql new file mode 100644 index 000000000000..2c24afe8d0ac --- /dev/null +++ b/src/lib/db/migrations/104_normalize_database_cache_size.sql @@ -0,0 +1,10 @@ +-- 104_normalize_database_cache_size.sql +-- Normalize the old SQLite cache-size default to the new UI contract: +-- databaseSettings.optimization.cacheSize is a positive KiB value, e.g. 16384. +-- Only legacy defaults are rewritten; custom positive values are preserved. + +UPDATE key_value +SET value = '16384' +WHERE namespace = 'databaseSettings' + AND key IN ('cacheSize', 'optimization.cacheSize') + AND TRIM(value) IN ('-2000', '"-2000"', '10000', '"10000"'); diff --git a/src/lib/db/optimizationSettings.ts b/src/lib/db/optimizationSettings.ts new file mode 100644 index 000000000000..636313c406d8 --- /dev/null +++ b/src/lib/db/optimizationSettings.ts @@ -0,0 +1,284 @@ +import { DEFAULT_DATABASE_SETTINGS, type DatabaseSettings } from "@/types/databaseSettings"; + +import type { SqliteAdapter } from "./adapters/types"; + +type SqliteDatabase = SqliteAdapter; +type DatabaseOptimizationSettings = DatabaseSettings["optimization"]; +type AutoVacuumMode = DatabaseOptimizationSettings["autoVacuumMode"]; + +const AUTO_VACUUM_MODE_TO_PRAGMA: Record = { + NONE: 0, + FULL: 1, + INCREMENTAL: 2, +}; + +const PRAGMA_TO_AUTO_VACUUM_MODE: Record = { + 0: "NONE", + 1: "FULL", + 2: "INCREMENTAL", +}; + +function parseKeyValueJson(raw: string | null | undefined): unknown { + if (raw === null || raw === undefined) return undefined; + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function normalizeAutoVacuumMode(value: unknown, fallback: AutoVacuumMode): AutoVacuumMode { + return typeof value === "string" && value in AUTO_VACUUM_MODE_TO_PRAGMA + ? (value as AutoVacuumMode) + : fallback; +} + +function normalizePageSizeBytes(value: unknown, fallback: number): number { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + const pageSize = Math.floor(numeric); + if (pageSize < 512 || pageSize > 65536 || pageSize % 512 !== 0) return fallback; + return pageSize; +} + +function normalizeVacuumHour(value: unknown, fallback: number): number { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(23, Math.max(0, Math.floor(numeric))); +} + +function normalizeStoredCacheSizeKb(value: unknown, fallback: number): number { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + const cacheSizeKb = Math.floor(numeric); + if (cacheSizeKb < 1 || cacheSizeKb > 1000000) return fallback; + return cacheSizeKb; +} + +function requireCacheSizeKb(value: number): number { + if (!Number.isInteger(value) || value < 1 || value > 1000000) { + throw new Error("cache_size must be a positive KiB value between 1 and 1000000"); + } + return value; +} + +function mergeOptimizationSettings( + target: DatabaseOptimizationSettings, + value: unknown +): DatabaseOptimizationSettings { + if (!isRecord(value)) return target; + return { + ...target, + autoVacuumMode: normalizeAutoVacuumMode(value.autoVacuumMode, target.autoVacuumMode), + scheduledVacuum: + typeof value.scheduledVacuum === "string" && + ["never", "daily", "weekly", "monthly"].includes(value.scheduledVacuum) + ? (value.scheduledVacuum as DatabaseOptimizationSettings["scheduledVacuum"]) + : target.scheduledVacuum, + vacuumHour: normalizeVacuumHour(value.vacuumHour, target.vacuumHour), + pageSize: normalizePageSizeBytes(value.pageSize, target.pageSize), + cacheSize: normalizeStoredCacheSizeKb(value.cacheSize, target.cacheSize), + optimizeOnStartup: + typeof value.optimizeOnStartup === "boolean" + ? value.optimizeOnStartup + : target.optimizeOnStartup, + }; +} + +function readDatabaseOptimizationSettings(db: SqliteDatabase): DatabaseOptimizationSettings { + let settings: DatabaseOptimizationSettings = { ...DEFAULT_DATABASE_SETTINGS.optimization }; + + try { + const rows = db + .prepare("SELECT namespace, key, value FROM key_value WHERE namespace IN (?, ?)") + .all("settings", "databaseSettings") as Array<{ + namespace: string; + key: string; + value: string | null; + }>; + const byNamespace: Record> = { + settings: {}, + databaseSettings: {}, + }; + for (const row of rows) { + byNamespace[row.namespace] ??= {}; + byNamespace[row.namespace][row.key] = parseKeyValueJson(row.value); + } + + const mainSettings = byNamespace.settings ?? {}; + const databaseSettingsValue = mainSettings.databaseSettings; + if (isRecord(databaseSettingsValue)) { + settings = mergeOptimizationSettings(settings, databaseSettingsValue.optimization); + } + settings = mergeOptimizationSettings(settings, mainSettings.optimization); + + const databaseSettings = byNamespace.databaseSettings ?? {}; + const optimizeOnStartup = + databaseSettings["optimization.optimizeOnStartup"] ?? databaseSettings.optimizeOnStartup; + settings = mergeOptimizationSettings(settings, databaseSettings.optimization); + settings = { + ...settings, + autoVacuumMode: normalizeAutoVacuumMode( + databaseSettings["optimization.autoVacuumMode"] ?? databaseSettings.autoVacuumMode, + settings.autoVacuumMode + ), + pageSize: normalizePageSizeBytes( + databaseSettings["optimization.pageSize"] ?? databaseSettings.pageSize, + settings.pageSize + ), + cacheSize: normalizeStoredCacheSizeKb( + databaseSettings["optimization.cacheSize"] ?? databaseSettings.cacheSize, + settings.cacheSize + ), + optimizeOnStartup: + typeof optimizeOnStartup === "boolean" ? optimizeOnStartup : settings.optimizeOnStartup, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[DB] Failed to read database optimization settings; using defaults: ${message}`); + } + + return settings; +} + +export function setCacheSizeForDb(db: SqliteDatabase, cacheSizeKb: number): void { + const normalizedCacheSizeKb = requireCacheSizeKb(cacheSizeKb); + const currentCacheSize = db.pragma("cache_size", { simple: true }) as number; + const targetCacheSize = -normalizedCacheSizeKb; + + if (currentCacheSize === targetCacheSize) { + console.log(`[DB] cache_size already set to ${normalizedCacheSizeKb}KB`); + return; + } + + console.log( + `[DB] Changing cache_size from ${Math.abs(currentCacheSize)}KB to ${normalizedCacheSizeKb}KB` + ); + db.pragma(`cache_size = ${targetCacheSize}`); + + const newCacheSize = db.pragma("cache_size", { simple: true }) as number; + if (newCacheSize !== targetCacheSize) { + throw new Error( + `cache_size change did not take effect (expected ${targetCacheSize}, got ${newCacheSize})` + ); + } + console.log(`[DB] cache_size changed to ${Math.abs(newCacheSize)}KB`); +} + +function applyPersistentOptimizationPragmas( + db: SqliteDatabase, + settings: DatabaseOptimizationSettings +): void { + const targetAutoVacuum = AUTO_VACUUM_MODE_TO_PRAGMA[settings.autoVacuumMode]; + const targetPageSize = normalizePageSizeBytes( + settings.pageSize, + DEFAULT_DATABASE_SETTINGS.optimization.pageSize + ); + const currentAutoVacuum = db.pragma("auto_vacuum", { simple: true }) as number; + const currentPageSize = db.pragma("page_size", { simple: true }) as number; + + if (currentAutoVacuum === targetAutoVacuum && currentPageSize === targetPageSize) return; + + const originalJournalMode = String( + db.pragma("journal_mode", { simple: true }) ?? "" + ).toUpperCase(); + const shouldRestoreWal = originalJournalMode === "WAL"; + + console.log( + `[DB] Applying persistent optimization settings ` + + `(auto_vacuum ${currentAutoVacuum}->${targetAutoVacuum}, ` + + `page_size ${currentPageSize}->${targetPageSize})` + ); + + try { + if (shouldRestoreWal) db.pragma("journal_mode = DELETE"); + db.pragma(`auto_vacuum = ${targetAutoVacuum}`); + db.pragma(`page_size = ${targetPageSize}`); + db.exec("VACUUM"); + } finally { + if (shouldRestoreWal) { + try { + db.pragma("journal_mode = WAL"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[DB] Failed to restore WAL mode after optimization settings: ${message}`); + } + } + } + + const newAutoVacuum = db.pragma("auto_vacuum", { simple: true }) as number; + const newPageSize = db.pragma("page_size", { simple: true }) as number; + if (newAutoVacuum !== targetAutoVacuum || newPageSize !== targetPageSize) { + throw new Error( + `database optimization settings did not take effect ` + + `(auto_vacuum expected ${targetAutoVacuum}, got ${newAutoVacuum}; ` + + `page_size expected ${targetPageSize}, got ${newPageSize})` + ); + } +} + +export function applyDatabaseOptimizationSettingsForDb( + db: SqliteDatabase, + settings: DatabaseOptimizationSettings, + options: { applyPersistent: boolean } +): void { + if (options.applyPersistent) applyPersistentOptimizationPragmas(db, settings); + setCacheSizeForDb( + db, + normalizeStoredCacheSizeKb(settings.cacheSize, DEFAULT_DATABASE_SETTINGS.optimization.cacheSize) + ); +} + +export function applyStoredDatabaseOptimizationSettings(db: SqliteDatabase): void { + const settings = readDatabaseOptimizationSettings(db); + // Startup can happen concurrently in test workers and clustered hosts. Only + // restore connection-local settings here; page_size/auto_vacuum require VACUUM + // and are applied synchronously when the Storage settings are saved. + applyDatabaseOptimizationSettingsForDb(db, settings, { + applyPersistent: false, + }); +} + +export function setAutoVacuumForDb(db: SqliteDatabase, mode: AutoVacuumMode): void { + const currentMode = db.pragma("auto_vacuum", { simple: true }) as number; + const targetMode = AUTO_VACUUM_MODE_TO_PRAGMA[mode]; + + if (currentMode === targetMode) { + console.log(`[DB] auto_vacuum already set to ${mode}`); + return; + } + + applyPersistentOptimizationPragmas(db, { + ...DEFAULT_DATABASE_SETTINGS.optimization, + autoVacuumMode: mode, + pageSize: db.pragma("page_size", { simple: true }) as number, + }); +} + +export function getAutoVacuumModeForDb(db: SqliteDatabase): AutoVacuumMode { + const mode = db.pragma("auto_vacuum", { simple: true }) as number; + return PRAGMA_TO_AUTO_VACUUM_MODE[mode] || "NONE"; +} + +export function setPageSizeForDb(db: SqliteDatabase, pageSize: number): void { + const currentPageSize = db.pragma("page_size", { simple: true }) as number; + const targetPageSize = normalizePageSizeBytes( + pageSize, + DEFAULT_DATABASE_SETTINGS.optimization.pageSize + ); + + if (currentPageSize === targetPageSize) { + console.log(`[DB] page_size already set to ${targetPageSize}`); + return; + } + + applyPersistentOptimizationPragmas(db, { + ...DEFAULT_DATABASE_SETTINGS.optimization, + autoVacuumMode: getAutoVacuumModeForDb(db), + pageSize: targetPageSize, + }); +} diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 9a8888bacd32..6c69834894eb 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -400,7 +400,7 @@ export const databaseSettingsSchema = z .or(z.literal("monthly")), vacuumHour: z.number().int().min(0).max(23), pageSize: z.number().multipleOf(512).min(512).max(65536), - cacheSize: z.number().int().min(-1000000).max(1000000), + cacheSize: z.number().int().positive().max(1000000), optimizeOnStartup: z.boolean(), }), diff --git a/src/types/databaseSettings.ts b/src/types/databaseSettings.ts index 18cf3e119ffe..e07f0682445f 100644 --- a/src/types/databaseSettings.ts +++ b/src/types/databaseSettings.ts @@ -118,7 +118,7 @@ export const DEFAULT_DATABASE_SETTINGS: Omit { assert.ok(INTENTIONALLY_INTERNAL.size > 0, "INTENTIONALLY_INTERNAL must not be empty"); }); -test("INTENTIONALLY_INTERNAL contains the expected 29 audited modules", () => { +test("INTENTIONALLY_INTERNAL contains the expected 30 audited modules", () => { const expected = [ "_rowTypes", "accessTokens", @@ -141,6 +141,7 @@ test("INTENTIONALLY_INTERNAL contains the expected 29 audited modules", () => { "migrationRunner", "notion", "obsidian", + "optimizationSettings", "pluginMetrics", "prompts", "providerNodeSelect", diff --git a/tests/unit/database-settings-maintenance.test.ts b/tests/unit/database-settings-maintenance.test.ts index b29ee88430af..b8dbf20d6f8c 100644 --- a/tests/unit/database-settings-maintenance.test.ts +++ b/tests/unit/database-settings-maintenance.test.ts @@ -146,6 +146,81 @@ test("database log settings mirror the runtime pipeline toggle", async () => { assert.equal(databaseSettings.getUserDatabaseSettings().logs.callLogPipelineEnabled, true); }); +test("database optimization settings apply SQLite cache size immediately", () => { + const current = databaseSettings.getUserDatabaseSettings(); + + databaseSettings.updateDatabaseSettings({ + optimization: { + ...current.optimization, + autoVacuumMode: core.getAutoVacuumMode(), + pageSize: 4096, + cacheSize: 16384, + }, + }); + + const db = core.getDbInstance(); + const stored = db + .prepare( + "SELECT value FROM key_value WHERE namespace = 'databaseSettings' AND key = 'optimization.cacheSize'" + ) + .get() as { value: string } | undefined; + + assert.equal(db.pragma("cache_size", { simple: true }), -16384); + assert.equal(JSON.parse(stored?.value ?? "null"), 16384); + assert.equal(databaseSettings.getUserDatabaseSettings().optimization.cacheSize, 16384); +}); + +test("database optimization settings apply SQLite page size immediately", () => { + const current = databaseSettings.getUserDatabaseSettings(); + + databaseSettings.updateDatabaseSettings({ + optimization: { + ...current.optimization, + autoVacuumMode: core.getAutoVacuumMode(), + pageSize: 8192, + cacheSize: 16384, + }, + }); + + assert.equal(core.getDbInstance().pragma("page_size", { simple: true }), 8192); + assert.equal(databaseSettings.getUserDatabaseSettings().optimization.pageSize, 8192); +}); + +test("database optimization cache size is applied when the DB is reopened", () => { + const db = core.getDbInstance(); + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('databaseSettings', ?, ?)" + ).run("optimization.cacheSize", JSON.stringify(32768)); + + core.resetDbInstance(); + const reopened = core.getDbInstance(); + + assert.equal(reopened.pragma("cache_size", { simple: true }), -32768); +}); + +test("database optimization rejects negative cache size through the API", async () => { + const current = databaseSettings.getUserDatabaseSettings(); + const response = await databaseSettingsRoute.PATCH( + makeJsonRequest("PATCH", { + optimization: { + ...current.optimization, + cacheSize: -2000, + }, + }) as never + ); + + assert.equal(response.status, 400); +}); + +test("database settings reader normalizes legacy negative cache size to the positive default", () => { + const db = core.getDbInstance(); + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('databaseSettings', ?, ?)" + ).run("optimization.cacheSize", JSON.stringify(-2000)); + + assert.equal(databaseSettings.getUserDatabaseSettings().optimization.cacheSize, 16384); +}); + test("purgeDetailedLogs deletes request_detail_logs", async () => { const db = core.getDbInstance(); db.prepare("INSERT INTO request_detail_logs (id, timestamp, duration_ms) VALUES (?, ?, ?)").run( From 108e93e14b7cf55e9593c1868a06552a0fd8eea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89der=20Costa?= Date: Tue, 23 Jun 2026 22:58:21 -0300 Subject: [PATCH 054/120] fix(sse): drop ccp pin when pinned provider is durably unhealthy (failover + anti-flap) (#4864) Integrated into release/v3.8.36 --- config/quality/file-size-baseline.json | 2 +- open-sse/services/combo.ts | 103 ++++++++++++++++++++++- tests/unit/combo-pin-health-gate.test.ts | 77 +++++++++++++++++ 3 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 tests/unit/combo-pin-health-gate.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 3b21d5761c3e..ecc9c4715557 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -140,7 +140,7 @@ "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, "_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.", - "open-sse/services/combo.ts": 3069, + "open-sse/services/combo.ts": 3168, "open-sse/services/compression/strategySelector.ts": 848, "open-sse/services/rateLimitManager.ts": 1035, "open-sse/services/tokenRefresh.ts": 2070, diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 46ec4f62ccad..6fc911a25610 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -582,6 +582,73 @@ export async function buildAutoCandidates( }); } +const TERMINAL_PIN_STATUSES = new Set(["credits_exhausted", "banned", "expired"]); + +/** + * Pure decision: should a context-cache pin be DROPPED because its provider has + * DURABLY fallen? A ccp pin keeps the prompt cache warm by bypassing the combo + * strategy — but if the pinned provider is dead (credits exhausted / banned / + * expired, circuit-open, repeated failures, or a long rate-limit) honoring the + * pin pounds a dead account forever with no failover (laila throttle + credits + * incidents, 2026-06-22). A brief transient cooldown is tolerated (pin kept) so + * an unstable provider does not churn the pin every turn. Connection-level + * `backoffLevel` already resets on success, so `backoffLevel >= K` ≈ K + * consecutive failures — no per-session counter needed. + * + * Returns true ⇒ drop the pin and use the strategy. Pure + unit-testable. + */ +export function pinIsDurablyUnhealthy( + circuitState: string | undefined, + connections: Array<{ + testStatus?: string | null; + backoffLevel?: number | null; + rateLimitedUntil?: string | null; + }>, + now: number, + opts: { backoffLevel?: number; graceMs?: number } = {} +): boolean { + if (circuitState === "OPEN") return true; + if (!Array.isArray(connections) || connections.length === 0) return true; + const backoffThreshold = + opts.backoffLevel ?? Number(process.env.PIN_DROP_BACKOFF_LEVEL || "2"); + const graceMs = opts.graceMs ?? Number(process.env.PIN_DROP_GRACE_MS || "20000"); + // The pin survives as long as AT LEAST ONE connection is healthy or only + // briefly cooling down — failover only when every connection is durably down. + const anyUsable = connections.some((c) => { + const status = typeof c.testStatus === "string" ? c.testStatus : ""; + if (TERMINAL_PIN_STATUSES.has(status)) return false; + if (Number(c.backoffLevel ?? 0) >= backoffThreshold) return false; + const rl = c.rateLimitedUntil ? new Date(String(c.rateLimitedUntil)).getTime() : 0; + if (Number.isFinite(rl) && rl - now > graceMs) return false; + return true; + }); + return !anyUsable; +} + +/** + * Async wrapper: resolve the pinned model's provider, read its circuit state and + * active connections, and decide via {@link pinIsDurablyUnhealthy}. Fail-open + * (return false) on any error so a lookup bug never drops a healthy pin. + */ +async function isPinnedModelDurablyUnhealthy(pinnedModel: string): Promise { + try { + const provider = parseModel(pinnedModel).provider; + if (!provider) return false; + const circuitState = getCircuitBreaker(provider)?.getStatus?.()?.state; + const connections = (await getProviderConnections({ + provider, + isActive: true, + })) as Array<{ + testStatus?: string | null; + backoffLevel?: number | null; + rateLimitedUntil?: string | null; + }>; + return pinIsDurablyUnhealthy(circuitState, connections || [], Date.now()); + } catch { + return false; + } +} + /** * Handle combo chat with fallback. * @param {Object} options @@ -731,9 +798,41 @@ export async function handleComboChat({ // Route to pinned model if context caching specifies one (Fix #679) if (pinnedModel) { - log.info( + // The pin is read from session_model_history (a PRIOR turn) and may name a + // model that has since been removed from this combo, or a provider whose + // credentials are gone. Without this guard a stale pin bypasses the strategy + // and routes to a dead model forever — incident 2026-06-21: cli-claude-heavy + // pinned to a deepseek connection with no active credentials → instant fail, + // never falling through to the live targets; and combos re-pointed Opus→Sonnet + // kept serving the old model. Validate the pin is still reachable in THIS + // combo's resolved targets (refs flattened) before honoring it. Only validate + // when allCombos is authoritative (non-empty) so we can resolve combo-refs; + // the auto-combo redirect path passes an empty list and keeps prior behavior. + const haveFullCombos = Array.isArray(allCombos) ? allCombos.length > 0 : !!allCombos; + const pinInCombo = + !haveFullCombos || + resolveComboTargets(combo, allCombos, clampComboDepth(config.maxComboDepth)).some( + (t) => t.modelStr === pinnedModel + ); + // Honor the pin only if it is still a combo target AND its provider is not + // DURABLY down. Without the health gate a pin keeps routing a session to a + // dead/credits-exhausted/throttled account forever (strategy bypassed, no + // failover) — incident 2026-06-22: laila stuck on a throttled claude account + // and credits_exhausted accounts never failing over. A transient cooldown is + // tolerated (pin kept) so an unstable provider does not churn the pin. + const pinDurablyDown = pinInCombo ? await isPinnedModelDurablyUnhealthy(pinnedModel) : false; + if (pinInCombo && !pinDurablyDown) { + log.info( + "COMBO", + `Bypassing strategy — routing directly to pinned context model: ${pinnedModel}` + ); + return handleSingleModelWithTimeout(body, pinnedModel); + } + log.warn( "COMBO", - `Bypassing strategy — routing directly to pinned context model: ${pinnedModel}` + pinInCombo + ? `Context-cache pin "${pinnedModel}" provider durably unhealthy — dropping pin, using strategy` + : `Stale context-cache pin "${pinnedModel}" not in combo "${combo.name}" targets — dropping pin, using strategy` ); return handleSingleModelWithTimeout(body, pinnedModel); } diff --git a/tests/unit/combo-pin-health-gate.test.ts b/tests/unit/combo-pin-health-gate.test.ts new file mode 100644 index 000000000000..ad02650975f2 --- /dev/null +++ b/tests/unit/combo-pin-health-gate.test.ts @@ -0,0 +1,77 @@ +/** + * ccp pin health gate — drop a context-cache pin when its provider is DURABLY + * down so the session fails over instead of pounding a dead account, while + * tolerating transient cooldowns so an unstable provider does not churn the pin. + * + * Incident 2026-06-22: a session pinned to a throttled/credits-exhausted account + * stayed pinned forever (strategy bypassed, no failover) because the pin was only + * dropped when the model LEFT the combo, never on connection health. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { pinIsDurablyUnhealthy } from "../../open-sse/services/combo.ts"; + +const NOW = 1_900_000_000_000; +const opts = { backoffLevel: 2, graceMs: 20_000 }; +const healthy = { testStatus: "active", backoffLevel: 0, rateLimitedUntil: null }; + +test("keeps pin when a healthy connection exists", () => { + assert.equal(pinIsDurablyUnhealthy("CLOSED", [healthy], NOW, opts), false); +}); + +test("drops pin when circuit is OPEN", () => { + assert.equal(pinIsDurablyUnhealthy("OPEN", [healthy], NOW, opts), true); +}); + +test("drops pin when there are no active connections", () => { + assert.equal(pinIsDurablyUnhealthy("CLOSED", [], NOW, opts), true); +}); + +test("drops pin when the only connection has credits exhausted (terminal)", () => { + const conn = { testStatus: "credits_exhausted", backoffLevel: 0, rateLimitedUntil: null }; + assert.equal(pinIsDurablyUnhealthy("CLOSED", [conn], NOW, opts), true); +}); + +test("drops pin on banned/expired terminal status", () => { + assert.equal( + pinIsDurablyUnhealthy("CLOSED", [{ testStatus: "banned", backoffLevel: 0 }], NOW, opts), + true + ); + assert.equal( + pinIsDurablyUnhealthy("CLOSED", [{ testStatus: "expired", backoffLevel: 0 }], NOW, opts), + true + ); +}); + +test("drops pin once backoffLevel reaches the threshold (repeated failures)", () => { + assert.equal( + pinIsDurablyUnhealthy("CLOSED", [{ testStatus: "active", backoffLevel: 2 }], NOW, opts), + true + ); +}); + +test("anti-flap: keeps pin on a brief transient cooldown (low backoff, short rate-limit)", () => { + const conn = { + testStatus: "active", + backoffLevel: 1, + rateLimitedUntil: new Date(NOW + 4_000).toISOString(), // 4s out — within grace + }; + assert.equal(pinIsDurablyUnhealthy("CLOSED", [conn], NOW, opts), false); +}); + +test("drops pin on a long rate-limit window (beyond grace)", () => { + const conn = { + testStatus: "active", + backoffLevel: 0, + rateLimitedUntil: new Date(NOW + 60_000).toISOString(), // 60s out — durable + }; + assert.equal(pinIsDurablyUnhealthy("CLOSED", [conn], NOW, opts), true); +}); + +test("keeps pin if ANY connection is usable (terminal + healthy mix)", () => { + const conns = [ + { testStatus: "credits_exhausted", backoffLevel: 0 }, + healthy, + ]; + assert.equal(pinIsDurablyUnhealthy("CLOSED", conns, NOW, opts), false); +}); From 86b14add6e509e8085dba1fe2f61ff666c52921d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89der=20Costa?= Date: Tue, 23 Jun 2026 22:59:51 -0300 Subject: [PATCH 055/120] fix(claude): skip mcp__ tool-name cloak + guard missing connectionId (#4861) Integrated into release/v3.8.36 --- open-sse/services/claudeCodeToolRemapper.ts | 6 ++++++ src/sse/handlers/chat.ts | 2 +- tests/unit/claude-oauth-tool-cloak.test.ts | 8 ++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/open-sse/services/claudeCodeToolRemapper.ts b/open-sse/services/claudeCodeToolRemapper.ts index 9ab697a1009b..754ecc978a20 100644 --- a/open-sse/services/claudeCodeToolRemapper.ts +++ b/open-sse/services/claudeCodeToolRemapper.ts @@ -194,6 +194,12 @@ function toPascalCaseToolName(name: string): string { export function needsThirdPartyCloak(name: string): boolean { if (!name) return false; if (CLAUDE_BUILTIN_TOOL_NAMES.has(name)) return false; + // `mcp____` names are genuine Claude Code MCP tool names that + // Anthropic accepts natively. Cloaking them to PascalCase is unnecessary and, + // via round-trip asymmetry (a history tool_use keeping the original name while + // tools[] is cloaked), produces "Tool reference 'mcp__…' not found in available + // tools" 400s on the native claude OAuth path. Leave the MCP namespace alone. + if (name.startsWith("mcp__")) return false; return /[a-z]/.test(name.charAt(0)) || name.includes("_") || name.includes("-"); } diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 58f732adc303..03ef7f5d05c8 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -1013,7 +1013,7 @@ async function handleSingleModelChat( ); preselectedCredentials = null; - if (!credentials || "allRateLimited" in credentials) { + if (!credentials || "allRateLimited" in credentials || !credentials.connectionId) { if (credentials?.allRateLimited) { const retryDecision = getCooldownAwareRetryDecision({ retryAfter: credentials.retryAfter, diff --git a/tests/unit/claude-oauth-tool-cloak.test.ts b/tests/unit/claude-oauth-tool-cloak.test.ts index 2fdb3c71b549..46bffc1d9026 100644 --- a/tests/unit/claude-oauth-tool-cloak.test.ts +++ b/tests/unit/claude-oauth-tool-cloak.test.ts @@ -109,6 +109,14 @@ describe("cloakThirdPartyToolNames", () => { assert.equal(needsThirdPartyCloak("mixture_of_agents"), true); }); + it("needsThirdPartyCloak leaves mcp__ namespace untouched (#4861)", () => { + // Genuine Claude Code MCP names Anthropic accepts natively; cloaking them + // caused round-trip "Tool reference 'mcp__…' not found" 400s on claude OAuth. + assert.equal(needsThirdPartyCloak("mcp__filesystem__read_file"), false); + assert.equal(needsThirdPartyCloak("mcp__github__create_issue"), false); + assert.equal(needsThirdPartyCloak("mcp__server"), false); + }); + it("preserves the reserved name of a versioned Anthropic server tool", () => { const body: AnyRecord = { tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 5 }], From 4d61198e1a3d5c52126aa54ba4b81f9e34130708 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:05:18 -0300 Subject: [PATCH 056/120] chore(quality): reconcile env-doc + file-size base-reds in release/v3.8.36 (#4886) - env-doc-sync: document PIN_DROP_BACKOFF_LEVEL / PIN_DROP_GRACE_MS (added by the ccp-pin health gate #4864) in .env.example + ENVIRONMENT.md. - file-size: rebaseline image-generation-handler.test.ts 1996 -> 2019 to its actual size (pre-existing drift). Co-authored-by: Diego Rodrigues de Sa e Souza --- .env.example | 9 +++++++++ config/quality/file-size-baseline.json | 2 +- docs/reference/ENVIRONMENT.md | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 68cebdba874b..8fe017ec21f8 100644 --- a/.env.example +++ b/.env.example @@ -955,6 +955,15 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" # OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD=2 # OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS=15000 +# ── Context-cache pin health gate ── +# Used by: open-sse/services/combo.ts. When a context-cache pin points at a +# provider that is durably unhealthy, the pin is dropped to allow failover. +# PIN_DROP_BACKOFF_LEVEL gates how deep a connection's backoff must be before the +# pin is considered durably unhealthy; PIN_DROP_GRACE_MS is the anti-flap window +# that tolerates brief transient cooldowns before dropping the pin. +# PIN_DROP_BACKOFF_LEVEL=2 +# PIN_DROP_GRACE_MS=20000 + # ── Stream idle detection ── # STREAM_IDLE_TIMEOUT_MS=600000 # Max silence between SSE chunks (default: 600000) # # Extended-thinking models rarely pause >90s. diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index ecc9c4715557..3878badf2829 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -242,7 +242,7 @@ "tests/unit/executor-codex.test.ts": 1339, "tests/unit/executor-default-base.test.ts": 1339, "tests/unit/grok-web.test.ts": 2437, - "tests/unit/image-generation-handler.test.ts": 1996, + "tests/unit/image-generation-handler.test.ts": 2019, "tests/unit/model-sync-route.test.ts": 1016, "tests/unit/models-catalog-route.test.ts": 1507, "_rebaseline_pr4561_qwen_oauth_url": "Reconcile #4561 (port decolua/9router#683) already-merged growth: oauth-providers-config.test.ts 855->867 (+12, qwen.ai URL regression-pin test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 6aa926c11d9f..24c46f9851c6 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -616,6 +616,8 @@ Provider-level circuit breaker tuning. Defaults reflect the scaled values used s | `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. | ### Scenarios From eb175db8ed9db1323989c8776efabc8b14c6a578 Mon Sep 17 00:00:00 2001 From: Jefferson Felizardo Date: Tue, 23 Jun 2026 23:07:51 -0300 Subject: [PATCH 057/120] fix(codex): drop non-standard codex.* events that break responses.stream (env-gated, #4602) (#4715) Integrated into release/v3.8.36 --- .env.example | 6 + config/quality/file-size-baseline.json | 2 +- docs/reference/ENVIRONMENT.md | 1 + open-sse/executors/codex.ts | 103 ++++++++++++++++-- .../codex-drop-nonstandard-events.test.ts | 51 +++++++++ 5 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 tests/unit/codex-drop-nonstandard-events.test.ts diff --git a/.env.example b/.env.example index 8fe017ec21f8..7a2cdc237e6d 100644 --- a/.env.example +++ b/.env.example @@ -830,6 +830,12 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" # CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts. # CODEX_CLIENT_VERSION=0.132.0 +# Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits) +# from the Codex Responses stream. These frames break the OpenAI SDK's +# responses.stream() with a 502 "Controller is already closed". Off by default; +# set to true/1/yes to enable. Used by: open-sse/executors/codex.ts. +# OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS=true + # ═══════════════════════════════════════════════════════════════════════════════ # 13. CLI FINGERPRINT COMPATIBILITY (Anti-Detection) # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 3878badf2829..75622029d5d9 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -116,7 +116,7 @@ "open-sse/executors/base.ts": 1414, "open-sse/executors/chatgpt-web.ts": 2870, "open-sse/executors/claude-web.ts": 1057, - "open-sse/executors/codex.ts": 1449, + "open-sse/executors/codex.ts": 1528, "open-sse/executors/cursor.ts": 1453, "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.", diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 24c46f9851c6..266c7fe90a02 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -576,6 +576,7 @@ REQUEST_TIMEOUT_MS (global override) | `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. | diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 03c2769beb93..ffa7b0d81dfe 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -754,6 +754,57 @@ function toCodexResponseFailedEvent(parsed: Record): Record { + const match = /^event:\s*(.+)$/m.exec(block); + return !!match && match[1].trim().startsWith("codex."); + }; + const transform = new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + let sep: number; + while ((sep = buffer.indexOf("\n\n")) !== -1) { + const block = buffer.slice(0, sep + 2); + buffer = buffer.slice(sep + 2); + if (!dropBlock(block)) controller.enqueue(encoder.encode(block)); + } + }, + flush(controller) { + if (buffer && !dropBlock(buffer)) controller.enqueue(encoder.encode(buffer)); + }, + }); + return new Response(response.body.pipeThrough(transform), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + export function encodeResponseSseEvent(raw: string): { sse: string; terminal: boolean } { let eventType = "message"; let payload = raw; @@ -775,6 +826,27 @@ export function encodeResponseSseEvent(raw: string): { sse: string; terminal: bo // Keep message as the generic SSE event for non-JSON upstream payloads. } + // Env-gated: drop non-standard `codex.*` events (notably `codex.rate_limits`) + // before they reach the client. They are NOT part of the OpenAI Responses API + // and break strict consumers: the OpenAI SDK's responses.stream() chokes on + // the unknown event type / empty data and tears the stream down, surfacing as + // "Invalid state: Controller is already closed". The earlier empty-payload + // check below never caught codex.rate_limits — over WS the frame carries a + // non-empty JSON payload (`{"type":"codex.rate_limits", ...}`), so + // `!payload.trim()` is false. Match by event type instead. Opt-in via + // OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS (the HTTP transport is handled + // separately by filterNonstandardCodexSse, since super.execute forwards the + // upstream stream verbatim and never runs this function). + if (eventType.startsWith("codex.") && codexDropNonstandardEvents()) { + return { sse: "", terminal }; + } + + // Drop frames whose raw payload is empty (defensive; non-JSON / blank upstream + // chunks). Frames that carry a payload are preserved. + if (!payload.trim()) { + return { sse: "", terminal }; + } + return { sse: `event: ${eventType}\ndata: ${payload}\n\n`, terminal }; } @@ -840,7 +912,14 @@ export class CodexExecutor extends BaseExecutor { const nextInput = { ...input, credentials }; if (!isCodexResponsesWebSocketRequired(nextInput.model, nextInput.credentials)) { - return super.execute(nextInput); + const httpResult = await super.execute(nextInput); + if (codexDropNonstandardEvents()) { + const resp = (httpResult as { response?: Response }).response; + if (resp?.body) { + (httpResult as { response: Response }).response = filterNonstandardCodexSse(resp); + } + } + return httpResult; } const url = CODEX_RESPONSES_WS_URL; @@ -973,15 +1052,19 @@ export class CodexExecutor extends BaseExecutor { : Buffer.from(event.data as Buffer).toString("utf8"); const sseEvent = encodeResponseSseEvent(raw); if (closed) return; - try { - controller.enqueue(encoder.encode(sseEvent.sse)); - } catch { - finishStream({ - reason: "downstream_closed", - emitDone: false, - closeController: false, - }); - return; + // Filtered events (codex.* / empty payload) return an empty `sse` — + // skip them so no empty frame reaches the client. + if (sseEvent.sse) { + try { + controller.enqueue(encoder.encode(sseEvent.sse)); + } catch { + finishStream({ + reason: "downstream_closed", + emitDone: false, + closeController: false, + }); + return; + } } if (sseEvent.terminal) { finishStream({ reason: "terminal_event" }); diff --git a/tests/unit/codex-drop-nonstandard-events.test.ts b/tests/unit/codex-drop-nonstandard-events.test.ts new file mode 100644 index 000000000000..8db3190f6ebf --- /dev/null +++ b/tests/unit/codex-drop-nonstandard-events.test.ts @@ -0,0 +1,51 @@ +// Regression guard for #4715: the Codex HTTP transport forwards the upstream SSE +// stream verbatim, including the non-standard `event: codex.rate_limits` frame +// (no `data:` line). That frame breaks the OpenAI SDK's responses.stream() with +// HTTP 502 "Controller is already closed". filterNonstandardCodexSse() strips +// every `codex.*` event block from the byte stream while preserving standard ones. +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { filterNonstandardCodexSse } from "../../open-sse/executors/codex.ts"; + +function sseResponse(body: string): Response { + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +async function readAll(res: Response): Promise { + return await res.text(); +} + +describe("filterNonstandardCodexSse (#4715)", () => { + it("drops codex.* event blocks but keeps standard response.* events", async () => { + const stream = + "event: response.created\ndata: {\"type\":\"response.created\"}\n\n" + + "event: codex.rate_limits\n\n" + + "event: response.output_text.delta\ndata: {\"delta\":\"hi\"}\n\n" + + "event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n"; + const out = await readAll(filterNonstandardCodexSse(sseResponse(stream))); + assert.ok(!out.includes("codex.rate_limits"), "codex.* frame must be stripped"); + assert.ok(out.includes("response.created"), "standard events preserved"); + assert.ok(out.includes("response.output_text.delta"), "standard delta preserved"); + assert.ok(out.includes("response.completed"), "terminal event preserved"); + }); + + it("passes through non-SSE responses untouched", async () => { + const json = new Response("{\"ok\":true}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + const out = filterNonstandardCodexSse(json); + assert.equal(await out.text(), "{\"ok\":true}"); + }); + + it("drops a trailing codex.* block with no double-newline terminator (flush path)", async () => { + const stream = + "event: response.created\ndata: {}\n\n" + "event: codex.token_count\ndata: {}"; + const out = await readAll(filterNonstandardCodexSse(sseResponse(stream))); + assert.ok(out.includes("response.created")); + assert.ok(!out.includes("codex.token_count")); + }); +}); From 2a7f2c892a7c27003fa98ea97237af84cac2865f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89der=20Costa?= Date: Tue, 23 Jun 2026 23:15:02 -0300 Subject: [PATCH 058/120] feat(routing): honor X-Route-Model header to override body.model (#4863) Integrated into release/v3.8.36 --- config/quality/file-size-baseline.json | 2 +- src/sse/handlers/chat.ts | 6 ++++- src/sse/handlers/resolveRoutingModel.ts | 17 +++++++++++++ tests/unit/resolve-routing-model.test.ts | 32 ++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 src/sse/handlers/resolveRoutingModel.ts create mode 100644 tests/unit/resolve-routing-model.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 75622029d5d9..cb897203d31c 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -217,7 +217,7 @@ "src/shared/constants/sidebarVisibility.ts": 1100, "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2523, - "src/sse/handlers/chat.ts": 1521, + "src/sse/handlers/chat.ts": 1525, "src/sse/services/auth.ts": 2289 }, "testCap": 800, diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 03ef7f5d05c8..d52a9fab0060 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -1,5 +1,6 @@ import { randomUUID } from "crypto"; import { resolveChatRequestBody } from "./requestBody"; +import { resolveRoutingModel } from "./resolveRoutingModel"; import { getProviderCredentialsWithQuotaPreflight, markAccountUnavailable, @@ -254,7 +255,10 @@ export async function handleChat( log.debug("NO_THINKING", `Resolved no-thinking alias → ${noThinking.realModel}`); } - let modelStr = body.model; + // X-Route-Model header overrides body.model for routing purposes (see + // resolveRoutingModel). The resolved model still passes through + // enforceApiKeyPolicy below, so it cannot bypass per-key allowlists. + let modelStr = resolveRoutingModel(request, body); // Count messages (support both messages[] and input[] formats) const msgCount = body.messages?.length || body.input?.length || 0; diff --git a/src/sse/handlers/resolveRoutingModel.ts b/src/sse/handlers/resolveRoutingModel.ts new file mode 100644 index 000000000000..c4c466b88e38 --- /dev/null +++ b/src/sse/handlers/resolveRoutingModel.ts @@ -0,0 +1,17 @@ +// Resolve the model used for routing. The `X-Route-Model` header, when present, +// overrides `body.model` — letting a caller/proxy force a specific combo/alias/model +// regardless of what the client CLI sent. This is useful when a CLI hardcodes +// `body.model` to a fixed provider/model (bypassing combo routing): an upstream +// proxy can send `X-Route-Model` to restore routing control without mutating the +// request body. The resolved value still flows through `enforceApiKeyPolicy`, so +// it cannot bypass per-key model/combo allowlists. See PR #4863. + +type HeaderCarrier = { headers: { get(name: string): string | null } }; + +export function resolveRoutingModel( + request: HeaderCarrier, + body: { model?: string | null } +): string | null | undefined { + const headerModel = request.headers.get("x-route-model")?.trim(); + return headerModel || body.model; +} diff --git a/tests/unit/resolve-routing-model.test.ts b/tests/unit/resolve-routing-model.test.ts new file mode 100644 index 000000000000..b9a3c53b9eb9 --- /dev/null +++ b/tests/unit/resolve-routing-model.test.ts @@ -0,0 +1,32 @@ +// Regression guard for #4863: X-Route-Model header overrides body.model for routing. +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { resolveRoutingModel } from "../../src/sse/handlers/resolveRoutingModel.ts"; + +function req(headers: Record) { + return { headers: { get: (n: string) => headers[n.toLowerCase()] ?? null } }; +} + +describe("resolveRoutingModel (#4863)", () => { + it("uses body.model when no X-Route-Model header is present", () => { + assert.equal(resolveRoutingModel(req({}), { model: "gpt-5.3-codex" }), "gpt-5.3-codex"); + }); + + it("X-Route-Model header overrides body.model", () => { + assert.equal( + resolveRoutingModel(req({ "x-route-model": "my-combo" }), { model: "codex/gpt-5.3-codex" }), + "my-combo" + ); + }); + + it("trims surrounding whitespace from the header value", () => { + assert.equal( + resolveRoutingModel(req({ "x-route-model": " alias-x " }), { model: "fallback" }), + "alias-x" + ); + }); + + it("falls back to body.model when the header is empty/whitespace-only", () => { + assert.equal(resolveRoutingModel(req({ "x-route-model": " " }), { model: "fallback" }), "fallback"); + }); +}); From c522454c799a28b3323ced645c4202a57ca1d52b Mon Sep 17 00:00:00 2001 From: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:21:51 -0700 Subject: [PATCH 059/120] feat(live-ws): allow non-loopback clients via LIVE_WS_ALLOWED_HOSTS (closes #4873) (#4877) Integrated into release/v3.8.36 (live-ws + combo-api commits; Tailscale CGNAT commit held pending opt-in/opt-out decision) --- .../dashboard/combos/live/comboFlowModel.ts | 7 +- src/app/api/combos/[id]/route.ts | 148 ++++++++++----- src/app/api/combos/route.ts | 20 ++- src/hooks/useLiveDashboard.ts | 9 +- src/lib/api/comboErrorResponse.ts | 80 +++++++++ src/server/ws/liveServer.ts | 50 +++--- src/server/ws/liveServerAllowList.ts | 106 +++++++++++ src/shared/components/flow/FlowCanvas.tsx | 48 ++++- src/shared/constants/errorCodes.ts | 44 +++++ tests/unit/api/combo-error-response.test.ts | 116 ++++++++++++ .../unit/combo-routes-composite-tiers.test.ts | 38 ++-- .../unit/server/live-server-allowlist.test.ts | 168 ++++++++++++++++++ vitest.config.ts | 2 + 13 files changed, 734 insertions(+), 102 deletions(-) create mode 100644 src/lib/api/comboErrorResponse.ts create mode 100644 src/server/ws/liveServerAllowList.ts create mode 100644 tests/unit/api/combo-error-response.test.ts create mode 100644 tests/unit/server/live-server-allowlist.test.ts diff --git a/src/app/(dashboard)/dashboard/combos/live/comboFlowModel.ts b/src/app/(dashboard)/dashboard/combos/live/comboFlowModel.ts index 75230b16b6aa..9a6c8c774797 100644 --- a/src/app/(dashboard)/dashboard/combos/live/comboFlowModel.ts +++ b/src/app/(dashboard)/dashboard/combos/live/comboFlowModel.ts @@ -25,6 +25,7 @@ * *into* each target node carries the target's state colour. */ +import type { CSSProperties } from "react"; import type { Node, Edge } from "@xyflow/react"; import { edgeStyle } from "@/shared/components/flow/edgeStyles"; @@ -285,7 +286,7 @@ export function comboRunToFlow(run: ComboRunModel): { nodes: Node[]; edges: Edge // Edge: request → strategy (always active/idle depending on run state) const runActive = run.outcome === "running"; - const reqStratStyle = edgeStyle(runActive, !runActive, false); + const reqStratStyle = edgeStyle(runActive, !runActive, false) as unknown as CSSProperties; edges.push({ id: `e-${requestId}-${strategyId}`, source: requestId, @@ -324,7 +325,7 @@ export function comboRunToFlow(run: ComboRunModel): { nodes: Node[]; edges: Edge const isError = t.state === "failed"; const isActive = t.state === "succeeded"; const isLast = t.state === "attempting"; - const targetEdgeStyle = edgeStyle(isActive, isLast, isError); + const targetEdgeStyle = edgeStyle(isActive, isLast, isError) as unknown as CSSProperties; edges.push({ id: `e-${prevId}-${nodeId}`, @@ -351,7 +352,7 @@ export function comboRunToFlow(run: ComboRunModel): { nodes: Node[]; edges: Edge // Edge: last target → response — green if succeeded, muted otherwise const succeeded = run.outcome === "succeeded"; - const responseEdgeStyle = edgeStyle(succeeded, false, false); + const responseEdgeStyle = edgeStyle(succeeded, false, false) as unknown as CSSProperties; edges.push({ id: `e-${prevId}-${responseId}`, source: prevId, diff --git a/src/app/api/combos/[id]/route.ts b/src/app/api/combos/[id]/route.ts index 22dff324e445..46f62807278e 100644 --- a/src/app/api/combos/[id]/route.ts +++ b/src/app/api/combos/[id]/route.ts @@ -15,8 +15,25 @@ import { validateComboDAG, clampComboDepth } from "@omniroute/open-sse/services/ import { updateComboSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { buildErrorBody } from "@omniroute/open-sse/utils/error"; import { QUOTA_MODEL_PREFIX } from "@/lib/quota/quotaModelNaming"; +import { comboErrorResponse } from "@/lib/api/comboErrorResponse"; + +// Minimal shape for the fields we read off a combo row in this route. +// `getComboById` returns a structurally `JsonRecord`-typed object, so we +// narrow at the call sites rather than change the DB helper's return type. +type ComboRowShape = { + name: string; + id?: string; + config?: unknown; + models?: unknown; + strategy?: string; + isActive?: boolean; + allowedProviders?: string[]; + system_message?: string; + tool_filter_regex?: string; + context_cache_protection?: boolean; + context_length?: number | null; +}; /** * Keys that were present in older combo configs (≤ v3.8.31) but have since been @@ -69,13 +86,13 @@ export async function GET(request, { params }) { const combo = await getComboById(id); if (!combo) { - return NextResponse.json({ error: "Combo not found" }, { status: 404 }); + return comboErrorResponse("COMBO_007", 404, { id }, request); } return NextResponse.json(combo); } catch (error) { console.log("Error fetching combo:", error); - return NextResponse.json({ error: "Failed to fetch combo" }, { status: 500 }); + return comboErrorResponse("INTERNAL_001", 500, undefined, request); } } @@ -88,14 +105,11 @@ export async function PUT(request, { params }) { try { rawBody = await request.json(); } catch { - return NextResponse.json( - { - error: { - message: "Invalid request", - details: [{ field: "body", message: "Invalid JSON body" }], - }, - }, - { status: 400 } + return comboErrorResponse( + "COMBO_001", + 400, + { field: "body", reason: "Invalid JSON body" }, + request ); } @@ -103,19 +117,23 @@ export async function PUT(request, { params }) { const { id } = await params; const validation = validateBody(updateComboSchema, rawBody); if (isValidationFailure(validation)) { - return NextResponse.json({ error: validation.error }, { status: 400 }); + return comboErrorResponse( + "COMBO_002", + 400, + { issues: validation.error }, + request + ); } - const currentCombo = await getComboById(id); + const currentCombo = (await getComboById(id)) as ComboRowShape | null; if (!currentCombo) { - return NextResponse.json({ error: "Combo not found" }, { status: 404 }); + return comboErrorResponse("COMBO_007", 404, { id }, request); } if (currentCombo.name.startsWith(QUOTA_MODEL_PREFIX)) { - return NextResponse.json( - buildErrorBody( - 409, - "This combo is managed by Quota Share and cannot be edited here. Manage it from the Quota Share page." - ), - { status: 409 } + return comboErrorResponse( + "COMBO_006", + 409, + { name: currentCombo.name, source: "quota-share" }, + request ); } const allCombos = await getCombos(); @@ -124,17 +142,17 @@ export async function PUT(request, { params }) { const normalizedUpdate = { ...validation.data }; if (normalizedUpdate.compressionOverride !== undefined) { const legacyCompressionOverride = normalizedUpdate.compressionOverride; - const nextConfig = - currentCombo.config && - typeof currentCombo.config === "object" && - !Array.isArray(currentCombo.config) - ? { ...currentCombo.config } - : {}; - if (legacyCompressionOverride) { - nextConfig.compressionMode = legacyCompressionOverride; - } else { - delete nextConfig.compressionMode; - } + const nextConfig: Record = + currentCombo.config && + typeof currentCombo.config === "object" && + !Array.isArray(currentCombo.config) + ? { ...(currentCombo.config as Record) } + : {}; + if (legacyCompressionOverride) { + nextConfig.compressionMode = legacyCompressionOverride; + } else { + delete nextConfig.compressionMode; + } normalizedUpdate.config = nextConfig; delete normalizedUpdate.compressionOverride; } @@ -146,8 +164,12 @@ export async function PUT(request, { params }) { ? { ...normalizedUpdate, models: normalizeComboModels(normalizedUpdate.models, { - comboName, - allCombos, + comboName: String(comboName), + // `allCombos` from `getCombos()` is typed as the DB-shaped record + // (JsonRecord & { version: 2; models: ComboStep[] }) which is + // structurally compatible with the local ComboCollectionLike in + // `normalizeComboModels` but TS does not infer the relationship. + allCombos: allCombos as never, }), } : normalizedUpdate; @@ -157,15 +179,29 @@ export async function PUT(request, { params }) { name: comboName, }; const compositeValidation = validateCompositeTiersConfig(nextComboState); - if (!compositeValidation.success) { - return NextResponse.json({ error: compositeValidation.error }, { status: 400 }); + if (compositeValidation.success === false) { + const failure = compositeValidation as { + success: false; + error: { message: string; details: unknown[] }; + }; + return comboErrorResponse( + "COMBO_003", + 400, + { reason: failure.error.message, details: failure.error.details }, + request + ); } // Check if name already exists (exclude current combo) if (body.name) { const existing = await getComboByName(body.name); if (existing && existing.id !== id) { - return NextResponse.json({ error: "Combo name already exists" }, { status: 400 }); + return comboErrorResponse( + "COMBO_004", + 400, + { name: body.name, conflictingId: existing.id }, + request + ); } } @@ -178,9 +214,24 @@ export async function PUT(request, { params }) { (nextComboState as { config?: { maxComboDepth?: unknown } }).config?.maxComboDepth ); try { - validateComboDAG(comboName, updatedCombos, new Set(), 0, configuredDepth); + validateComboDAG(String(comboName), updatedCombos, new Set(), 0, configuredDepth); } catch (dagError) { - return NextResponse.json({ error: dagError.message }, { status: 400 }); + // Sanitize the raw `dagError.message` — it can leak internal combo + // names. Log full error server-side for debugging, return a + // sanitized generic message to the client with a short reason tag. + console.warn("Combo DAG validation failed:", dagError); + const reason = + dagError instanceof Error && /cycle/i.test(dagError.message) + ? "cycle-detected" + : dagError instanceof Error && /depth/i.test(dagError.message) + ? "max-depth-exceeded" + : "invalid-graph"; + return comboErrorResponse( + "COMBO_005", + 400, + { comboName, reason }, + request + ); } } } @@ -193,7 +244,7 @@ export async function PUT(request, { params }) { return NextResponse.json(combo); } catch (error) { console.log("Error updating combo:", error); - return NextResponse.json({ error: "Failed to update combo" }, { status: 500 }); + return comboErrorResponse("INTERNAL_001", 500, undefined, request); } } @@ -204,23 +255,22 @@ export async function DELETE(request, { params }) { try { const { id } = await params; - const existingCombo = await getComboById(id); + const existingCombo = (await getComboById(id)) as ComboRowShape | null; if (!existingCombo) { - return NextResponse.json({ error: "Combo not found" }, { status: 404 }); + return comboErrorResponse("COMBO_007", 404, { id }, request); } if (existingCombo.name.startsWith(QUOTA_MODEL_PREFIX)) { - return NextResponse.json( - buildErrorBody( - 409, - "This combo is managed by Quota Share and cannot be deleted here. Manage it from the Quota Share page." - ), - { status: 409 } + return comboErrorResponse( + "COMBO_006", + 409, + { name: existingCombo.name, source: "quota-share" }, + request ); } const success = await deleteCombo(id); if (!success) { - return NextResponse.json({ error: "Combo not found" }, { status: 404 }); + return comboErrorResponse("COMBO_007", 404, { id }, request); } // Auto sync to Cloud if enabled @@ -229,7 +279,7 @@ export async function DELETE(request, { params }) { return NextResponse.json({ success: true }); } catch (error) { console.log("Error deleting combo:", error); - return NextResponse.json({ error: "Failed to delete combo" }, { status: 500 }); + return comboErrorResponse("INTERNAL_001", 500, undefined, request); } } diff --git a/src/app/api/combos/route.ts b/src/app/api/combos/route.ts index 2817f3fd067c..ec29ae535193 100644 --- a/src/app/api/combos/route.ts +++ b/src/app/api/combos/route.ts @@ -8,6 +8,7 @@ import { validateComboDAG, clampComboDepth } from "@omniroute/open-sse/services/ import { createComboSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { comboErrorResponse } from "@/lib/api/comboErrorResponse"; // GET /api/combos - Get all combos export async function GET(request: Request) { @@ -39,7 +40,11 @@ export async function POST(request) { const allCombos = await getCombos(); const normalizedModels = normalizeComboModels(validation.data.models, { comboName: validation.data.name, - allCombos, + // `allCombos` from `getCombos()` is typed as the DB-shaped record + // (JsonRecord & { version: 2; models: ComboStep[] }) which is + // structurally compatible with the local ComboCollectionLike in + // `normalizeComboModels` but TS does not infer the relationship. + allCombos: allCombos as never, }); const comboInput = { ...validation.data, @@ -47,8 +52,17 @@ export async function POST(request) { }; const { name, strategy, config } = comboInput; const compositeValidation = validateCompositeTiersConfig(comboInput); - if (!compositeValidation.success) { - return NextResponse.json({ error: compositeValidation.error }, { status: 400 }); + if (compositeValidation.success === false) { + const failure = compositeValidation as { + success: false; + error: { message: string; details: unknown[] }; + }; + return comboErrorResponse( + "COMBO_003", + 400, + { reason: failure.error.message, details: failure.error.details }, + request + ); } // Check if name already exists diff --git a/src/hooks/useLiveDashboard.ts b/src/hooks/useLiveDashboard.ts index bd72bdb57fa1..d5b5dae1bfe1 100644 --- a/src/hooks/useLiveDashboard.ts +++ b/src/hooks/useLiveDashboard.ts @@ -21,10 +21,15 @@ function getDefaultWsUrl(): string { if (typeof window === "undefined") return "ws://localhost:20129"; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const { hostname } = window.location; - if (hostname === "localhost" || hostname === "127.0.0.1") { + // Bug #1 fix: Use the WS server's actual port (20129) for both loopback + // and non-loopback clients. Previously the non-loopback branch tried to + // upgrade the HTTP port (window.location.host) which has no upgrade + // handler in src/proxy.ts. If the user wants the upgrade to go through + // Next.js (same-origin), they should explicitly pass `wsUrl`. + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") { return `${protocol}//${hostname}:20129`; } - return `${protocol}//${window.location.host}/live-ws`; + return `${protocol}//${hostname}:20129`; } const DEFAULT_WS_URL = getDefaultWsUrl(); diff --git a/src/lib/api/comboErrorResponse.ts b/src/lib/api/comboErrorResponse.ts new file mode 100644 index 000000000000..11436ca0ba3c --- /dev/null +++ b/src/lib/api/comboErrorResponse.ts @@ -0,0 +1,80 @@ +/** + * Combo API error helper (T-22.b). + * + * Standardizes the 400/404/409 response shape for `/api/combos/*` routes. + * Every failure surfaces a stable machine-readable `code` token (from + * `src/shared/constants/errorCodes.ts`), a human-readable `message`, an + * optional `details` payload, and the current `requestId` for log + * correlation. The prior shape returned `{ error: }` with + * no `code` field, which forced clients to string-match English error + * messages. See `plans/2026-06-23-omniroute-v3.8.34-deep-audit.md` (Bug #3). + * + * Usage: + * return comboErrorResponse("COMBO_002", 400, { issues: validation.issues }); + * return comboErrorResponse("COMBO_005", 400, { reason: "cycle-detected" }); + * + * The response is a plain `Response` (not `NextResponse.json`) so it can + * be used from both Next.js route handlers and server-side callers. The + * `x-request-id` header is also attached for downstream log correlation. + */ + +import { ERROR_CODES } from "@/shared/constants/errorCodes"; +import { + attachRequestIdToResponse, + getRequestId, +} from "@/shared/utils/requestId"; + +export type ComboErrorCode = + | "COMBO_001" // request body is not valid JSON + | "COMBO_002" // zod schema failure + | "COMBO_003" // composite tier config invalid + | "COMBO_004" // name collision + | "COMBO_005" // DAG cycle / depth overflow + | "COMBO_006" // managed by Quota Share (409) + | "COMBO_007" // not found (404) + | "VALID_001" // generic invalid body + | "VALID_002" // missing required field + | "INTERNAL_001"; // fallback + +export interface ComboErrorBody { + error: { + code: ComboErrorCode; + message: string; + category: string; + details?: unknown; + requestId?: string; + }; +} + +export function buildComboErrorBody( + code: ComboErrorCode, + details?: unknown +): ComboErrorBody { + const def = ERROR_CODES[code] ?? ERROR_CODES.INTERNAL_001; + const requestId = getRequestId(); + return { + error: { + code: def.code as ComboErrorCode, + message: def.message, + category: def.category, + ...(details !== undefined ? { details } : {}), + ...(requestId ? { requestId } : {}), + }, + }; +} + +export function comboErrorResponse( + code: ComboErrorCode, + status?: number, + details?: unknown, + request?: Request +): Response { + const def = ERROR_CODES[code] ?? ERROR_CODES.INTERNAL_001; + const httpStatus = status ?? def.httpStatus; + const body = buildComboErrorBody(code, details); + const response = Response.json(body, { status: httpStatus }); + // Attach x-request-id header for downstream consumers. If a request is + // passed, prefer to derive its id (works outside withRequestId scope); + // otherwise the response body already carries requestId when available. + return request ? attachRequestIdToResponse(request, response) : response; +} diff --git a/src/server/ws/liveServer.ts b/src/server/ws/liveServer.ts index ccc1f7e8e91a..127554557263 100644 --- a/src/server/ws/liveServer.ts +++ b/src/server/ws/liveServer.ts @@ -20,7 +20,12 @@ import { randomUUID } from "crypto"; // ── Types ───────────────────────────────────────────────────────────────── -import type { WsClientMessage, WsServerMessage, WsAuthResult } from "./types"; +import type { + WsClientMessage, + WsServerMessage, + WsEventMessage, + WsAuthResult, +} from "./types"; import { emit, on, onAny, getEventHistory, type HistoryEntry } from "@/lib/events/eventBus"; @@ -28,6 +33,12 @@ import type { DashboardEventName, DashboardEventMap, DashboardChannel } from "@/ import { CHANNEL_EVENTS, getChannelForEvent } from "@/lib/events/types"; +import { + buildAllowedOrigins, + buildAllowedHosts, + isOriginAllowed as isOriginAllowedPure, +} from "./liveServerAllowList"; + // ── Config ──────────────────────────────────────────────────────────────── const DEFAULT_PORT = 20129; @@ -42,36 +53,21 @@ const MAX_EVENTS_PER_SECOND = 100; const MAX_PENDING_MESSAGES_PER_CLIENT = 32; const MAX_PENDING_MESSAGE_BYTES = 16_384; +const ALLOWED_ORIGINS = buildAllowedOrigins(); +const ALLOWED_HOSTS = buildAllowedHosts(); + /** - * Origins allowed to open a WebSocket. Defaults to the loopback dashboard - * origins; admins can extend via LIVE_WS_ALLOWED_ORIGINS (comma-separated). + * Whether the given Origin is acceptable for a WS upgrade. * - * WS does not honour CORS — a malicious page on origin X can otherwise open - * a WebSocket to our server and ride the user's API key (if it lives in a - * cookie or is reachable through the page). Browsers DO send the Origin - * header on the WS upgrade, so checking it server-side is the standard - * mitigation. Non-browser clients (CLI, MCP) omit Origin, which we accept. + * Delegates to `liveServerAllowList` for the actual policy; this wrapper + * exists so the connection handler can read the closure-bound allow-lists + * without re-parsing env on every connection. */ -function buildAllowedOrigins(): Set { - const base = [`http://127.0.0.1:20128`, `http://localhost:20128`, `http://[::1]:20128`]; - const extra = (process.env.LIVE_WS_ALLOWED_ORIGINS || "") - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - return new Set([...base, ...extra]); -} - -const ALLOWED_ORIGINS = buildAllowedOrigins(); - function isOriginAllowed(origin: string | undefined): boolean { - // Non-browser client (curl, native ws, MCP) — Origin header is omitted by - // spec. Allow only when the upstream listener is bound to loopback; if the - // operator opted into LAN exposure we require an explicit Origin. - if (!origin) { - const host = process.env.LIVE_WS_HOST || DEFAULT_HOST; - return host === "127.0.0.1" || host === "::1" || host === "localhost"; - } - return ALLOWED_ORIGINS.has(origin); + return isOriginAllowedPure(origin, process.env, { + allowedOrigins: ALLOWED_ORIGINS, + allowedHosts: ALLOWED_HOSTS, + }); } // ── Client State ────────────────────────────────────────────────────────── diff --git a/src/server/ws/liveServerAllowList.ts b/src/server/ws/liveServerAllowList.ts new file mode 100644 index 000000000000..9e3a91d59833 --- /dev/null +++ b/src/server/ws/liveServerAllowList.ts @@ -0,0 +1,106 @@ +/** + * liveServerAllowList — extract of the host/origin allow-list logic from + * `src/server/ws/liveServer.ts`. + * + * Lives in its own module so unit tests can exercise it without booting the + * full WebSocket server. The behaviour here MUST match the one used by the + * connection handler exactly. + * + * Bug #1 (plans/2026-06-23-omniroute-v3.8.34-deep-audit.md) added the + * `LIVE_WS_ALLOWED_HOSTS` opt-in for LAN/Tailscale deployments. + */ + +const DEFAULT_HOST = "127.0.0.1"; + +/** + * Default origins allowed to open a WebSocket against the local dashboard. + * These match the loopback HTTP listener at port 20128. + */ +export const DEFAULT_ALLOWED_ORIGINS: readonly string[] = Object.freeze([ + "http://127.0.0.1:20128", + "http://localhost:20128", + "http://[::1]:20128", +]); + +/** + * Parse a comma-separated env value into a set of trimmed, non-empty entries. + * Centralized so tests can exercise empty / whitespace / dup behaviour. + */ +export function parseCsvEnv(value: string | undefined | null): Set { + return new Set( + (value ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + ); +} + +/** + * Build the static origin allow-list from defaults + LIVE_WS_ALLOWED_ORIGINS. + */ +export function buildAllowedOrigins(env: NodeJS.ProcessEnv = process.env): Set { + const extra = parseCsvEnv(env.LIVE_WS_ALLOWED_ORIGINS); + return new Set([...DEFAULT_ALLOWED_ORIGINS, ...extra]); +} + +/** + * Build the host-based allow-list (LAN/Tailscale extension). + */ +export function buildAllowedHosts(env: NodeJS.ProcessEnv = process.env): Set { + return parseCsvEnv(env.LIVE_WS_ALLOWED_HOSTS); +} + +/** + * Parse the host portion of an Origin URL. + * + * Returns `null` when the input is not a well-formed absolute URL — callers + * should treat `null` as "not a match". + */ +export function originHost(origin: string): { host: string; hostname: string } | null { + try { + const url = new URL(origin); + return { host: url.host, hostname: url.hostname }; + } catch { + return null; + } +} + +/** + * Whether the given Origin's host (or `host:port`) is in the host + * allow-list. Returns false when the list is empty. + */ +export function originHostMatches(origin: string, allowedHosts: Set): boolean { + if (allowedHosts.size === 0) return false; + const parsed = originHost(origin); + if (!parsed) return false; + return allowedHosts.has(parsed.host) || allowedHosts.has(parsed.hostname); +} + +/** + * Top-level Origin allow decision. The contract: + * + * - When `origin` is undefined (no Origin header, e.g. CLI/MCP), we only + * accept the request when the WS listener is bound to loopback. This + * prevents drive-by LAN clients from omitting Origin to bypass the + * browser-side check. + * + * - When `origin` is present, we accept it if it matches an entry in the + * static origin list (defaults + LIVE_WS_ALLOWED_ORIGINS) or if its + * host matches an entry in the LAN allow-list (LIVE_WS_ALLOWED_HOSTS). + */ +export function isOriginAllowed( + origin: string | undefined, + env: NodeJS.ProcessEnv = process.env, + options: { allowedOrigins?: Set; allowedHosts?: Set } = {} +): boolean { + const allowedOrigins = options.allowedOrigins ?? buildAllowedOrigins(env); + const allowedHosts = options.allowedHosts ?? buildAllowedHosts(env); + + if (!origin) { + const host = env.LIVE_WS_HOST || DEFAULT_HOST; + return host === "127.0.0.1" || host === "::1" || host === "localhost"; + } + if (allowedOrigins.has(origin)) return true; + if (originHostMatches(origin, allowedHosts)) return true; + return false; +} diff --git a/src/shared/components/flow/FlowCanvas.tsx b/src/shared/components/flow/FlowCanvas.tsx index acf8f8768348..d27c7ed9230d 100644 --- a/src/shared/components/flow/FlowCanvas.tsx +++ b/src/shared/components/flow/FlowCanvas.tsx @@ -9,6 +9,7 @@ import { type NodeTypes, type EdgeTypes, type NodeMouseHandler, + type ReactFlowInstance, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; @@ -42,6 +43,18 @@ type FlowCanvasProps = { * behavioural change: auto-fit on init, on resize (ResizeObserver) and on node * count change; attribution hidden; read-only by default. Shared by the home * topology, the Combo/Routing Studio (Tela B) and the Compression Studio (Tela A). + * + * ## Stability fix (Bug #4 — plans/2026-06-23-omniroute-v3.8.34-deep-audit.md) + * + * Earlier revisions captured the ReactFlow instance in a plain `useRef` that + * outlived remounts. When the parent re-rendered with a new `fitKey`, the + * `` remounted the graph (a brand-new instance), + * but the `useEffect`s at the bottom of this file could still call + * `.fitView()` on the *stale* ref. React Flow walks its internal + * `nodeLookup` map and throws "Node cannot be found in the current page" + * when the lookup is in a transient state during a remount. The fix is a + * generation counter: every `onInit` bumps a counter, and every queued + * `fitView` call checks the counter before touching the instance. */ export function FlowCanvas({ nodes, @@ -54,12 +67,23 @@ export function FlowCanvas({ onNodeClick, children, }: FlowCanvasProps) { - const rfInstance = useRef<{ fitView: (opts: typeof FIT_VIEW_OPTIONS) => void } | null>(null); + const rfInstance = useRef(null); const containerRef = useRef(null); + // Bumped on every onInit so queued fitView calls can be invalidated when + // a new ReactFlow instance mounts (e.g. via fitKey change). + const generationRef = useRef(0); - const onInit = useCallback((instance: { fitView: (opts: typeof FIT_VIEW_OPTIONS) => void }) => { + const onInit = useCallback((instance: ReactFlowInstance) => { + const generation = ++generationRef.current; rfInstance.current = instance; - setTimeout(() => instance.fitView(FIT_VIEW_OPTIONS), REFIT_DELAY_MS); + // Defer fitView until ReactFlow has measured its viewport, but guard + // against the instance being replaced (generation mismatch) before the + // timer fires — see Bug #4 in the audit report. + setTimeout(() => { + if (generationRef.current === generation) { + instance.fitView(FIT_VIEW_OPTIONS); + } + }, REFIT_DELAY_MS); }, []); useEffect(() => { @@ -73,10 +97,26 @@ export function FlowCanvas({ }, []); useEffect(() => { - const id = setTimeout(() => rfInstance.current?.fitView(FIT_VIEW_OPTIONS), REFIT_DELAY_MS); + // Snapshot the generation so a queued callback that fires after a + // remount is silently dropped (no fitView on stale instance). + const generation = generationRef.current; + const id = setTimeout(() => { + if (generationRef.current === generation) { + rfInstance.current?.fitView(FIT_VIEW_OPTIONS); + } + }, REFIT_DELAY_MS); return () => clearTimeout(id); }, [nodes.length]); + // Clear the ref on unmount so a late-arriving callback (e.g. a ResizeObserver + // tick fired just before the React tree unmounted) cannot reach into a + // disposed ReactFlow instance. + useEffect(() => { + return () => { + rfInstance.current = null; + }; + }, []); + return (
= { category: "VALIDATION", }, + // ── Combos (T-22.b — 400-shape standardization for /api/combos/{id}) ── + COMBO_001: { + code: "COMBO_001", + message: "Request body is not valid JSON", + httpStatus: 400, + category: "COMBO", + }, + COMBO_002: { + code: "COMBO_002", + message: "One or more combo fields are invalid", + httpStatus: 400, + category: "COMBO", + }, + COMBO_003: { + code: "COMBO_003", + message: "Composite tier configuration is invalid", + httpStatus: 400, + category: "COMBO", + }, + COMBO_004: { + code: "COMBO_004", + message: "A combo with this name already exists", + httpStatus: 400, + category: "COMBO", + }, + COMBO_005: { + code: "COMBO_005", + message: "Combo reference graph is invalid (cycle or excessive depth)", + httpStatus: 400, + category: "COMBO", + }, + COMBO_006: { + code: "COMBO_006", + message: "This combo is managed by Quota Share and cannot be modified here", + httpStatus: 409, + category: "COMBO", + }, + COMBO_007: { + code: "COMBO_007", + message: "Combo not found", + httpStatus: 404, + category: "COMBO", + }, + // ── Internal ── INTERNAL_001: { code: "INTERNAL_001", diff --git a/tests/unit/api/combo-error-response.test.ts b/tests/unit/api/combo-error-response.test.ts new file mode 100644 index 000000000000..c0395882d585 --- /dev/null +++ b/tests/unit/api/combo-error-response.test.ts @@ -0,0 +1,116 @@ +/** + * Unit tests for the standardized combo error response helper. + * + * Bug #3 from `plans/2026-06-23-omniroute-v3.8.34-deep-audit.md`: + * every 4xx response from `/api/combos/{id}` must include a stable + * machine-readable `code` token, an `error.message`, optional + * `error.details`, and `requestId` correlation. These tests assert the + * shape and HTTP status for every branch the route uses. + */ + +import { describe, expect, it } from "vitest"; +import { + buildComboErrorBody, + comboErrorResponse, +} from "@/lib/api/comboErrorResponse"; +import { ERROR_CODES } from "@/shared/constants/errorCodes"; + +describe("buildComboErrorBody", () => { + it("emits the canonical { error: { code, message, category } } envelope", () => { + const body = buildComboErrorBody("COMBO_001"); + expect(body).toMatchObject({ + error: { + code: "COMBO_001", + message: "Request body is not valid JSON", + category: "COMBO", + }, + }); + }); + + it("includes details when provided", () => { + const body = buildComboErrorBody("COMBO_002", { + issues: [{ path: ["name"], message: "Required" }], + }); + expect(body.error.code).toBe("COMBO_002"); + expect(body.error.details).toEqual({ + issues: [{ path: ["name"], message: "Required" }], + }); + }); + + it("omits details when undefined", () => { + const body = buildComboErrorBody("COMBO_007"); + expect(body.error).not.toHaveProperty("details"); + }); + + it("falls back to INTERNAL_001 for an unknown code", () => { + const body = buildComboErrorBody( + "COMBO_999" as unknown as Parameters[0] + ); + // An unknown code falls through to INTERNAL_001 from the catalog. + expect(body.error.code).toBe("INTERNAL_001"); + }); +}); + +describe("comboErrorResponse", () => { + it("returns the catalog httpStatus when no override is given", async () => { + const res = comboErrorResponse("COMBO_001"); + expect(res.status).toBe(ERROR_CODES.COMBO_001.httpStatus); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.code).toBe("COMBO_001"); + }); + + it("respects an explicit status override", async () => { + const res = comboErrorResponse("COMBO_006", 409, { name: "qs:foo" }); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error.code).toBe("COMBO_006"); + expect(body.error.details).toEqual({ name: "qs:foo" }); + }); + + it("attaches x-request-id when a Request is supplied", async () => { + const req = new Request("https://example.com/api/combos/abc", { + headers: { "x-request-id": "test-corr-id-1234" }, + }); + const res = comboErrorResponse("COMBO_004", 400, undefined, req); + expect(res.headers.get("x-request-id")).toBeTruthy(); + }); + + it("does NOT leak internal combo names in DAG errors (sanitized reason tag)", async () => { + // The route should translate a thrown `Error("cycle detected: combo-A")` + // into `{ reason: "cycle-detected" }` — never the raw message. + const reason = /cycle/i.test("cycle detected: combo-A") ? "cycle-detected" : "invalid-graph"; + const res = comboErrorResponse("COMBO_005", 400, { + comboName: "user-facing-only", + reason, + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.code).toBe("COMBO_005"); + expect(body.error.details.reason).toBe("cycle-detected"); + // Crucial: the raw "combo-A" string must NOT appear in the response body. + const text = JSON.stringify(body); + expect(text).not.toContain("combo-A"); + }); +}); + +describe("all five route 4xx branches have a defined COMBO_* code", () => { + // The route uses these codes; this is a regression test against accidental + // removal of a code from the catalog (would break clients parsing `code`). + const expectedCodes = [ + "COMBO_001", // JSON parse failure (route.ts L49-58) + "COMBO_002", // zod schema failure (route.ts L65) + "COMBO_003", // composite tier config (route.ts L117) + "COMBO_004", // name collision (route.ts L124) + "COMBO_005", // DAG validation (route.ts L139) + "COMBO_006", // quota-share conflict 409 (route.ts L71-78) + "COMBO_007", // not found 404 (route.ts L31) + ] as const; + + it.each(expectedCodes)("%s is registered with httpStatus 400 or 409 or 404", (code) => { + const def = ERROR_CODES[code]; + expect(def).toBeDefined(); + expect([400, 404, 409]).toContain(def.httpStatus); + expect(def.category).toBe("COMBO"); + }); +}); diff --git a/tests/unit/combo-routes-composite-tiers.test.ts b/tests/unit/combo-routes-composite-tiers.test.ts index 40493faae168..2abda3e5abc8 100644 --- a/tests/unit/combo-routes-composite-tiers.test.ts +++ b/tests/unit/combo-routes-composite-tiers.test.ts @@ -154,13 +154,18 @@ test("POST /api/combos rejects composite tiers that point to unknown steps", asy assert.equal(response.status, 400); assert.deepEqual(body, { error: { - message: "Invalid composite tiers", - details: [ - { - field: "config.compositeTiers.tiers.primary.stepId", - message: 'stepId "step-missing" does not exist in combo.models', - }, - ], + code: "COMBO_003", + message: "Composite tier configuration is invalid", + category: "COMBO", + details: { + reason: "Invalid composite tiers", + details: [ + { + field: "config.compositeTiers.tiers.primary.stepId", + message: 'stepId "step-missing" does not exist in combo.models', + }, + ], + }, }, }); }); @@ -222,13 +227,18 @@ test("PUT /api/combos rejects updates that orphan an existing composite tier ste assert.equal(response.status, 400); assert.deepEqual(body, { error: { - message: "Invalid composite tiers", - details: [ - { - field: "config.compositeTiers.tiers.primary.stepId", - message: 'stepId "step-primary" does not exist in combo.models', - }, - ], + code: "COMBO_003", + message: "Composite tier configuration is invalid", + category: "COMBO", + details: { + reason: "Invalid composite tiers", + details: [ + { + field: "config.compositeTiers.tiers.primary.stepId", + message: 'stepId "step-primary" does not exist in combo.models', + }, + ], + }, }, }); }); diff --git a/tests/unit/server/live-server-allowlist.test.ts b/tests/unit/server/live-server-allowlist.test.ts new file mode 100644 index 000000000000..c75b7d6e6af1 --- /dev/null +++ b/tests/unit/server/live-server-allowlist.test.ts @@ -0,0 +1,168 @@ +/** + * Unit tests for `liveServerAllowList`. + * + * Bug #1 (plans/2026-06-23-omniroute-v3.8.34-deep-audit.md) introduced the + * `LIVE_WS_ALLOWED_HOSTS` opt-in for LAN/Tailscale deployments. These tests + * pin down the contract: defaults remain loopback-only; the env var extends + * the allow-list with bare hostnames or `host:port` pairs; the absence of + * Origin is still only acceptable on loopback. + */ + +import { describe, it, expect } from "vitest"; +import { + buildAllowedOrigins, + buildAllowedHosts, + isOriginAllowed, + originHost, + originHostMatches, + parseCsvEnv, + DEFAULT_ALLOWED_ORIGINS, +} from "@/server/ws/liveServerAllowList"; + +const EMPTY_ENV: NodeJS.ProcessEnv = {}; + +describe("parseCsvEnv", () => { + it("returns empty set for undefined", () => { + expect(parseCsvEnv(undefined).size).toBe(0); + }); + + it("returns empty set for empty string", () => { + expect(parseCsvEnv("").size).toBe(0); + }); + + it("trims whitespace and drops empty entries", () => { + const out = parseCsvEnv(" a , b ,, c "); + expect([...out]).toEqual(["a", "b", "c"]); + }); + + it("deduplicates entries", () => { + const out = parseCsvEnv("a,a,b"); + expect([...out]).toEqual(["a", "b"]); + }); +}); + +describe("buildAllowedOrigins", () => { + it("includes the loopback defaults", () => { + const out = buildAllowedOrigins(EMPTY_ENV); + for (const origin of DEFAULT_ALLOWED_ORIGINS) { + expect(out.has(origin)).toBe(true); + } + }); + + it("extends defaults with LIVE_WS_ALLOWED_ORIGINS", () => { + const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_ORIGINS: "https://dash.example.com,https://other.example.com" }; + const out = buildAllowedOrigins(env); + expect(out.has("https://dash.example.com")).toBe(true); + expect(out.has("https://other.example.com")).toBe(true); + // Defaults remain. + expect(out.has("http://localhost:20128")).toBe(true); + }); +}); + +describe("buildAllowedHosts", () => { + it("returns empty set when env is not set", () => { + expect(buildAllowedHosts(EMPTY_ENV).size).toBe(0); + }); + + it("parses comma-separated hosts", () => { + const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_HOSTS: "100.96.135.160,desktop.tailnet.ts.net" }; + const out = buildAllowedHosts(env); + expect(out.has("100.96.135.160")).toBe(true); + expect(out.has("desktop.tailnet.ts.net")).toBe(true); + }); +}); + +describe("originHost", () => { + it("returns host and hostname for a valid URL", () => { + expect(originHost("http://100.96.135.160:20128")).toEqual({ + host: "100.96.135.160:20128", + hostname: "100.96.135.160", + }); + }); + + it("returns null for an invalid URL", () => { + expect(originHost("not a url")).toBeNull(); + }); +}); + +describe("originHostMatches", () => { + it("returns false when the allow-list is empty", () => { + expect(originHostMatches("http://100.96.135.160:20128", new Set())).toBe(false); + }); + + it("matches by exact host:port", () => { + const allow = new Set(["100.96.135.160:20128"]); + expect(originHostMatches("http://100.96.135.160:20128", allow)).toBe(true); + }); + + it("matches by bare hostname regardless of port", () => { + const allow = new Set(["100.96.135.160"]); + expect(originHostMatches("http://100.96.135.160:20128", allow)).toBe(true); + expect(originHostMatches("http://100.96.135.160:55555", allow)).toBe(true); + }); + + it("returns false for a non-matching host", () => { + const allow = new Set(["100.96.135.160"]); + expect(originHostMatches("http://10.0.0.5:20128", allow)).toBe(false); + }); + + it("returns false for an unparseable origin", () => { + const allow = new Set(["100.96.135.160"]); + expect(originHostMatches("not-a-url", allow)).toBe(false); + }); +}); + +describe("isOriginAllowed", () => { + it("rejects any non-loopback origin by default", () => { + expect(isOriginAllowed("http://100.96.135.160:20128", EMPTY_ENV)).toBe(false); + }); + + it("accepts the default loopback origins", () => { + expect(isOriginAllowed("http://127.0.0.1:20128", EMPTY_ENV)).toBe(true); + expect(isOriginAllowed("http://localhost:20128", EMPTY_ENV)).toBe(true); + expect(isOriginAllowed("http://[::1]:20128", EMPTY_ENV)).toBe(true); + }); + + it("accepts an Origin matching LIVE_WS_ALLOWED_ORIGINS", () => { + const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_ORIGINS: "https://dash.example.com" }; + expect(isOriginAllowed("https://dash.example.com", env)).toBe(true); + }); + + it("accepts a Tailscale Origin when LIVE_WS_ALLOWED_HOSTS is set", () => { + const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_HOSTS: "100.96.135.160" }; + expect(isOriginAllowed("http://100.96.135.160:20128", env)).toBe(true); + }); + + it("accepts a Tailscale Origin matched by host:port when LIVE_WS_ALLOWED_HOSTS is set", () => { + const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_HOSTS: "100.96.135.160:20128" }; + expect(isOriginAllowed("http://100.96.135.160:20128", env)).toBe(true); + }); + + it("does NOT accept a Tailscale Origin when LIVE_WS_ALLOWED_HOSTS is unset", () => { + // Critical security invariant: without explicit opt-in, the LAN/Tailscale + // surface is closed even though the listener is reachable. + expect(isOriginAllowed("http://100.96.135.160:20128", EMPTY_ENV)).toBe(false); + }); + + it("rejects a missing Origin when bound to LAN (0.0.0.0)", () => { + // A CLI client that omits Origin should NOT be accepted when the + // operator opted into LAN exposure. Browsers always send Origin, so the + // empty-Origin path is for non-browser callers; the security stance is + // "refuse unless loopback". + const env = { ...EMPTY_ENV, LIVE_WS_HOST: "0.0.0.0" }; + expect(isOriginAllowed(undefined, env)).toBe(false); + }); + + it("accepts a missing Origin on loopback (CLI/MCP)", () => { + // CLI/MCP clients running on the same host omit Origin. The default + // listener (127.0.0.1) accepts them. + expect(isOriginAllowed(undefined, EMPTY_ENV)).toBe(true); + }); + + it("accepts a missing Origin on ::1 / localhost hosts", () => { + const env1 = { ...EMPTY_ENV, LIVE_WS_HOST: "::1" }; + expect(isOriginAllowed(undefined, env1)).toBe(true); + const env2 = { ...EMPTY_ENV, LIVE_WS_HOST: "localhost" }; + expect(isOriginAllowed(undefined, env2)).toBe(true); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index d448bdeb6c68..5932e550447d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,8 @@ export default defineConfig({ "src/lib/memory/__tests__/**/*.test.ts", "src/lib/skills/__tests__/**/*.test.ts", "tests/unit/encryption.test.ts", + "tests/unit/api/**/*.test.ts", + "tests/unit/server/**/*.test.ts", "tests/unit/**/*.test.tsx", "open-sse/**/__tests__/**/*.test.ts", "open-sse/services/**/__tests__/**/*.test.ts", From 2039095f6902a0bfa42b0ea98ce31d8172fe4a17 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:04:13 -0300 Subject: [PATCH 060/120] =?UTF-8?q?chore(claude,codex):=20bump=20pinned=20?= =?UTF-8?q?CLI=20identity=20=E2=80=94=20Claude=202.1.158=E2=86=922.1.187,?= =?UTF-8?q?=20Codex=200.132.0=E2=86=920.142.0=20(#4883)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 --- .env.example | 6 +-- docs/providers/AGENTROUTER.md | 2 +- docs/reference/ENVIRONMENT.md | 2 +- docs/security/STEALTH_GUIDE.md | 6 +-- open-sse/config/anthropicHeaders.ts | 2 +- open-sse/config/codexClient.ts | 2 +- open-sse/executors/claudeIdentity.ts | 2 +- open-sse/services/ccBridgeTransforms.ts | 2 +- open-sse/services/claudeCodeCompatible.ts | 4 +- tests/integration/chat-pipeline.test.ts | 2 +- ...claude-codex-identity-version-sync.test.ts | 47 +++++++++++++++++++ tests/unit/cli-tools.test.ts | 4 +- tests/unit/executor-codex.test.ts | 12 ++--- tests/unit/t20-t22-provider-headers.test.ts | 2 +- 14 files changed, 71 insertions(+), 24 deletions(-) create mode 100644 tests/unit/claude-codex-identity-version-sync.test.ts diff --git a/.env.example b/.env.example index 7a2cdc237e6d..c1a9d5ecce34 100644 --- a/.env.example +++ b/.env.example @@ -797,7 +797,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.158 (external, cli)" +CLAUDE_USER_AGENT="claude-cli/2.1.187 (external, cli)" # Disable the deterministic tool-name cloak applied on both Anthropic-bound paths # (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) — @@ -806,7 +806,7 @@ CLAUDE_USER_AGENT="claude-cli/2.1.158 (external, cli)" # stream with a misleading 400 out-of-extra-usage placeholder. Set to true to # forward the original names verbatim (debugging only). # CLAUDE_DISABLE_TOOL_NAME_CLOAK=false -CODEX_USER_AGENT="codex-cli/0.132.0 (Windows 10.0.26200; x64)" +CODEX_USER_AGENT="codex-cli/0.142.0 (Windows 10.0.26200; x64)" GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1" 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" @@ -828,7 +828,7 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" # Override Codex client version sent in headers independently of the # CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts. -# CODEX_CLIENT_VERSION=0.132.0 +# CODEX_CLIENT_VERSION=0.142.0 # Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits) # from the Codex Responses stream. These frames break the OpenAI SDK's diff --git a/docs/providers/AGENTROUTER.md b/docs/providers/AGENTROUTER.md index dcb9e18e7bf1..7130cc53d86b 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.158 (external, sdk-cli)` | +| `User-Agent` | `claude-cli/2.1.187 (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 266c7fe90a02..d9992880963c 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -471,7 +471,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | -------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (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.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | +| `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 | | `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | When Antigravity IDE updates | diff --git a/docs/security/STEALTH_GUIDE.md b/docs/security/STEALTH_GUIDE.md index 0983a126bf9a..8250882c6839 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.158 (external, sdk-cli)"` +- `CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.187 (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 @@ -213,8 +213,8 @@ All MITM endpoints require management auth (`requireCliToolsAuth`). The sudo pas | Variable | Default | | ------------------------ | --------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.158 (external, cli)` | -| `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.187 (external, cli)` | +| `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | | `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | | `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index 0cad3c043cec..3ddcadc550bb 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -64,7 +64,7 @@ export function mergeClientAnthropicBeta( return baseList.join(","); } -export const CLAUDE_CLI_VERSION = "2.1.158"; +export const CLAUDE_CLI_VERSION = "2.1.187"; 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/codexClient.ts b/open-sse/config/codexClient.ts index 746870df7326..1b6afa395f3a 100644 --- a/open-sse/config/codexClient.ts +++ b/open-sse/config/codexClient.ts @@ -1,4 +1,4 @@ -const DEFAULT_CODEX_CLIENT_VERSION = "0.132.0"; +const DEFAULT_CODEX_CLIENT_VERSION = "0.142.0"; const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26200"; const DEFAULT_CODEX_USER_AGENT_ARCH = "x64"; const CODEX_VERSION_OVERRIDE_ENV = "CODEX_CLIENT_VERSION"; diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index 470fd623133d..41728f8d5d26 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.158"; +export const CLAUDE_CODE_VERSION = "2.1.187"; /** Bundled @anthropic-ai/sdk version for the pinned CLI release. */ export const CLAUDE_CODE_STAINLESS_VERSION = "0.94.0"; diff --git a/open-sse/services/ccBridgeTransforms.ts b/open-sse/services/ccBridgeTransforms.ts index eb9a5f79a55d..d45c980dd3e7 100644 --- a/open-sse/services/ccBridgeTransforms.ts +++ b/open-sse/services/ccBridgeTransforms.ts @@ -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.158"; +export const DEFAULT_CLAUDE_CODE_VERSION = "2.1.187"; /** 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 60b22d218889..c8f07888617f 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -40,8 +40,8 @@ export { CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA, resolveClaudeCodeCompatibleAnthropicBeta, } from "./claudeCodeCompatibleBeta.ts"; -export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.158"; -export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.158 (external, sdk-cli)"; +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_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/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index 7627108d300c..001d03e8e68c 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -700,7 +700,7 @@ test("chat pipeline applies Codex CLI fingerprint to OAuth responses requests", assert.equal(call.headers.Version, getCodexClientVersion()); assert.equal(call.headers["Openai-Beta"], "responses=experimental"); assert.equal(call.headers["X-Codex-Beta-Features"], "responses_websockets"); - assert.equal(call.headers["User-Agent"], "codex-cli/0.132.0 (Windows 10.0.26200; x64)"); + assert.equal(call.headers["User-Agent"], "codex-cli/0.142.0 (Windows 10.0.26200; x64)"); assert.equal(call.headers["x-codex-window-id"], "conv_codex_fingerprint:0"); assert.ok(call.headers["x-client-request-id"], "expected Codex request id header"); assert.ok(call.headers["x-codex-turn-metadata"], "expected Codex turn metadata header"); diff --git a/tests/unit/claude-codex-identity-version-sync.test.ts b/tests/unit/claude-codex-identity-version-sync.test.ts new file mode 100644 index 000000000000..695e45f8f33f --- /dev/null +++ b/tests/unit/claude-codex-identity-version-sync.test.ts @@ -0,0 +1,47 @@ +/** + * tests/unit/claude-codex-identity-version-sync.test.ts + * + * Guards the pinned CLI identity versions against drift. The Claude Code version + * lives in FOUR places (claudeIdentity, anthropicHeaders, claudeCodeCompatible, + * ccBridgeTransforms) and MUST stay in lockstep — a partial bump produces an + * inconsistent wire fingerprint. The Codex client version lives in codexClient. + * + * When you capture a newer claude-cli / codex release, bump ALL constants and + * update the pinned values below in the same change. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +const id = await import("../../open-sse/executors/claudeIdentity.ts"); +const hdr = await import("../../open-sse/config/anthropicHeaders.ts"); +const compat = await import("../../open-sse/services/claudeCodeCompatible.ts"); +const bridge = await import("../../open-sse/services/ccBridgeTransforms.ts"); +const codexCfg = await import("../../open-sse/config/codexClient.ts"); + +test("Claude CLI version constants are in lockstep across all 4 sources", () => { + const V = id.CLAUDE_CODE_VERSION; + assert.equal(hdr.CLAUDE_CLI_VERSION, V, "anthropicHeaders.CLAUDE_CLI_VERSION drift"); + assert.equal(compat.CLAUDE_CODE_COMPATIBLE_VERSION, V, "claudeCodeCompatible version drift"); + assert.equal(bridge.DEFAULT_CLAUDE_CODE_VERSION, V, "ccBridgeTransforms version drift"); + assert.equal( + hdr.CLAUDE_CLI_USER_AGENT, + `claude-cli/${V} (external, cli)`, + "CLAUDE_CLI_USER_AGENT drift" + ); + assert.equal( + compat.CLAUDE_CODE_COMPATIBLE_USER_AGENT, + `claude-cli/${V} (external, sdk-cli)`, + "CLAUDE_CODE_COMPATIBLE_USER_AGENT drift" + ); +}); + +test("Claude CLI is pinned to the captured 2.1.187 release", () => { + assert.equal(id.CLAUDE_CODE_VERSION, "2.1.187"); +}); + +test("Codex client is pinned to the captured 0.142.0 release", () => { + assert.equal(codexCfg.getCodexClientVersion(), "0.142.0"); + assert.equal(codexCfg.getCodexUserAgent(), "codex-cli/0.142.0 (Windows 10.0.26200; x64)"); + assert.equal(codexCfg.getCodexDefaultHeaders().Version, "0.142.0"); +}); diff --git a/tests/unit/cli-tools.test.ts b/tests/unit/cli-tools.test.ts index dc5a4f267664..7886d51448da 100644 --- a/tests/unit/cli-tools.test.ts +++ b/tests/unit/cli-tools.test.ts @@ -87,12 +87,12 @@ test("CLI fingerprint preserves Codex executor User-Agent and maps legacy Copilo "codex", { Authorization: "Bearer token", - "User-Agent": "codex-cli/0.132.0 (Windows 10.0.26200; x64)", + "User-Agent": "codex-cli/0.142.0 (Windows 10.0.26200; x64)", }, { model: "gpt-5.5", messages: [], stream: true } ); - assert.equal(codex.headers["User-Agent"], "codex-cli/0.132.0 (Windows 10.0.26200; x64)"); + assert.equal(codex.headers["User-Agent"], "codex-cli/0.142.0 (Windows 10.0.26200; x64)"); assert.deepEqual(Object.keys(JSON.parse(codex.bodyString)), ["model", "stream", "messages"]); const copilot = applyFingerprint( diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index 673b2052713a..3ece99efe369 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -188,10 +188,10 @@ test("CodexExecutor.buildHeaders binds workspace ids and disables SSE accept for assert.equal(standardHeaders.Authorization, "Bearer codex-token"); assert.equal(standardHeaders.Accept, "text/event-stream"); assert.equal(standardHeaders["chatgpt-account-id"], "workspace-1"); - assert.equal(standardHeaders.Version, "0.132.0"); + assert.equal(standardHeaders.Version, "0.142.0"); assert.equal(standardHeaders["Openai-Beta"], "responses=experimental"); assert.equal(standardHeaders["X-Codex-Beta-Features"], "responses_websockets"); - assert.equal(standardHeaders["User-Agent"], "codex-cli/0.132.0 (Windows 10.0.26200; x64)"); + assert.equal(standardHeaders["User-Agent"], "codex-cli/0.142.0 (Windows 10.0.26200; x64)"); assert.equal(compactHeaders.Accept, "application/json"); }); @@ -200,13 +200,13 @@ test("CodexExecutor.buildHeaders honors safe env overrides for Version and User- await withEnv( { - CODEX_CLIENT_VERSION: "0.132.0", + CODEX_CLIENT_VERSION: "0.142.0", CODEX_USER_AGENT: undefined, }, () => { const headers = executor.buildHeaders({ accessToken: "codex-token" }, true); - assert.equal(headers.Version, "0.132.0"); - assert.equal(headers["User-Agent"], "codex-cli/0.132.0 (Windows 10.0.26200; x64)"); + assert.equal(headers.Version, "0.142.0"); + assert.equal(headers["User-Agent"], "codex-cli/0.142.0 (Windows 10.0.26200; x64)"); } ); @@ -217,7 +217,7 @@ test("CodexExecutor.buildHeaders honors safe env overrides for Version and User- }, () => { const headers = executor.buildHeaders({ accessToken: "codex-token" }, true); - assert.equal(headers.Version, "0.132.0"); + assert.equal(headers.Version, "0.142.0"); assert.equal(headers["User-Agent"], "custom-codex/9.9.9"); } ); diff --git a/tests/unit/t20-t22-provider-headers.test.ts b/tests/unit/t20-t22-provider-headers.test.ts index 5c6091551219..aa65bf17e6e7 100644 --- a/tests/unit/t20-t22-provider-headers.test.ts +++ b/tests/unit/t20-t22-provider-headers.test.ts @@ -66,7 +66,7 @@ test("T20: codex config advertises current client headers and supported models", assert.equal(codex.headers.Version, getCodexClientVersion()); assert.equal(codex.headers["Openai-Beta"], "responses=experimental"); assert.equal(codex.headers["X-Codex-Beta-Features"], "responses_websockets"); - assert.equal(codex.headers["User-Agent"], "codex-cli/0.132.0 (Windows 10.0.26200; x64)"); + assert.equal(codex.headers["User-Agent"], "codex-cli/0.142.0 (Windows 10.0.26200; x64)"); assert.ok(codex.models.some((model) => model.id === "gpt-5.5-medium")); assert.ok(!codex.models.some((model) => model.id === "codex-auto-review")); }); From 5c0cda929c2c3f2806f93c9978c31945c550ad48 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:04:17 -0300 Subject: [PATCH 061/120] fix(security): SSRF allowlist bypass via x-relay-path nos relays Deno/Vercel (#4899) Integrated into release/v3.8.36 --- .../api/settings/proxy/deno-deploy/route.ts | 80 +++++++++++++- .../api/settings/proxy/vercel-deploy/route.ts | 22 +++- .../unit/deno-deploy-relay-path-ssrf.test.ts | 104 ++++++++++++++++++ .../vercel-deploy-relay-path-ssrf.test.ts | 87 +++++++++++++++ 4 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 tests/unit/deno-deploy-relay-path-ssrf.test.ts create mode 100644 tests/unit/vercel-deploy-relay-path-ssrf.test.ts diff --git a/src/app/api/settings/proxy/deno-deploy/route.ts b/src/app/api/settings/proxy/deno-deploy/route.ts index 23f031fcf0ad..8b305770faf5 100644 --- a/src/app/api/settings/proxy/deno-deploy/route.ts +++ b/src/app/api/settings/proxy/deno-deploy/route.ts @@ -10,13 +10,74 @@ const DENO_API_BASE = process.env.DENO_DEPLOY_API_BASE || "https://api.deno.com/ const POLL_INTERVAL_MS = 2000; const POLL_MAX_ATTEMPTS = 30; // ~60 s +/** + * SSRF-safe resolution of the relay path against an already-validated target. + * + * The relay worker receives an attacker-controlled `x-relay-path` and must + * append it to the target ORIGIN without letting the caller swap the host that + * `x-relay-target` was validated for. The pre-#4643-fix worker did + * `target.replace(/\/$/, "") + relayPath`, which is bypassable via userinfo + * (`/x@evil.com`), a backslash (`\evil.com`), or a protocol-relative path + * (`//evil.com/x`). We instead resolve with `new URL(relayPath, targetUrl)` and + * re-assert that the resolved host/credentials still match the target. + * + * Pure (only `URL`, no Node/Deno globals) so the SAME source can be embedded + * verbatim into the Deno edge worker AND unit-tested directly in Node. Returns a + * tagged result instead of throwing so the worker can map it to an HTTP status. + */ +export function resolveRelayTarget( + target: string, + relayPath: string +): { ok: true; url: string } | { ok: false; status: 400 | 403; reason: string } { + let targetUrl; + try { + targetUrl = new URL(target); + } catch { + return { ok: false, status: 400, reason: "invalid x-relay-target" }; + } + // Reject the host-confusion vectors up front. A backslash or '@' has no place + // in a legitimate path/query, and `new URL` (special-scheme parsing) would + // otherwise treat them as authority/userinfo delimiters. + if ( + typeof relayPath !== "string" || + relayPath.indexOf("@") !== -1 || + relayPath.indexOf("\\") !== -1 || + relayPath.charAt(0) !== "/" + ) { + return { ok: false, status: 403, reason: "forbidden x-relay-path" }; + } + let finalUrl; + try { + finalUrl = new URL(relayPath, targetUrl); + } catch { + return { ok: false, status: 403, reason: "forbidden x-relay-path" }; + } + // A protocol-relative path ("//evil.com/x") resolves to a different host; + // userinfo would surface as username/password. Either means the path tried to + // re-point the request away from the validated target — reject. + if ( + finalUrl.hostname !== targetUrl.hostname || + finalUrl.protocol !== targetUrl.protocol || + finalUrl.port !== targetUrl.port || + finalUrl.username || + finalUrl.password + ) { + return { ok: false, status: 403, reason: "forbidden x-relay-path (host mismatch)" }; + } + return { ok: true, url: finalUrl.toString() }; +} + // Inlined Deno Deploy relay worker. The relayAuth secret is generated // server-side (no user input); the runtime SSRF guard is inlined into the // edge function because Deno Deploy isolates each app and cannot import -// Node-side helpers. Mirrors the Vercel-relay guard byte-for-byte so a -// future audit can diff the two and confirm they enforce the same policy. +// Node-side helpers. The path-resolution guard (`resolveRelayTarget`) is the +// SAME source used by the server and by the unit tests — embedded here via +// Function#toString so the worker enforces byte-for-byte the audited policy. +// Mirrors the Vercel-relay guard so a future audit can diff the two. function buildRelayWorker(relayAuth: string): string { - return `function isPrivateHostname(h) { + return `${resolveRelayTarget.toString()} + +function isPrivateHostname(h) { if (!h) return true; const host = h.trim().toLowerCase().replace(/^\\[|\\]$/g, ""); if ( @@ -62,6 +123,10 @@ Deno.serve(async (request) => { return new Response("forbidden x-relay-target (private/loopback host)", { status: 403 }); } const relayPath = request.headers.get("x-relay-path") || "/"; + const resolved = resolveRelayTarget(target, relayPath); + if (!resolved.ok) { + return new Response(resolved.reason, { status: resolved.status }); + } const headers = new Headers(request.headers); ["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach(h => headers.delete(h)); const init = { method: request.method, headers }; @@ -70,7 +135,7 @@ Deno.serve(async (request) => { init.duplex = "half"; } try { - const upstream = await fetch(target.replace(/\\/$/, "") + relayPath, init); + const upstream = await fetch(resolved.url, init); return new Response(upstream.body, { status: upstream.status, headers: upstream.headers }); } catch (error) { return new Response(JSON.stringify({ error: String(error && error.message || error) }), { @@ -81,6 +146,13 @@ Deno.serve(async (request) => { });`; } +/** + * Test-only hook exposing the generated worker source so the SSRF regression + * test can assert the worker no longer string-concatenates the relay path and + * embeds the shared `resolveRelayTarget` guard. Not part of the route contract. + */ +export const __buildRelayWorkerForTest = buildRelayWorker; + async function pollRevision( revisionId: string, token: string diff --git a/src/app/api/settings/proxy/vercel-deploy/route.ts b/src/app/api/settings/proxy/vercel-deploy/route.ts index f617cc2cf29b..eb46cfb33679 100644 --- a/src/app/api/settings/proxy/vercel-deploy/route.ts +++ b/src/app/api/settings/proxy/vercel-deploy/route.ts @@ -5,6 +5,10 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { vercelDeploySchema } from "@/shared/validation/freeProxySchemas"; import { createProxy } from "@/lib/localDb"; import { encrypt } from "@/lib/db/encryption"; +// Shared SSRF-safe relay-path resolver — the same pure guard embedded in the +// Deno Deploy worker. Both edge relays must enforce identical path validation, +// so they import one source of truth rather than diverging copies. +import { resolveRelayTarget } from "../deno-deploy/route"; const VERCEL_API_BASE = process.env.VERCEL_API_BASE || "https://api.vercel.com"; const POLL_INTERVAL_MS = 3000; @@ -15,8 +19,12 @@ function buildRelayFunction(relayAuth: string): string { // The runtime SSRF guard is inlined into the edge function (cannot import // Node-side helpers from the Edge runtime); it blocks RFC1918, loopback, // link-local, IPv6 ULA, and embedded credentials on the x-relay-target host. + // `resolveRelayTarget` (shared with the Deno worker) closes the x-relay-path + // host-confusion hole and is embedded verbatim via Function#toString. return `export const config = { runtime: "edge" }; +${resolveRelayTarget.toString()} + function isPrivateHostname(h) { if (!h) return true; const host = h.trim().toLowerCase().replace(/^\\[|\\]$/g, ""); @@ -63,9 +71,13 @@ export default async function handler(req) { return new Response("forbidden x-relay-target (private/loopback host)", { status: 403 }); } const relayPath = req.headers.get("x-relay-path") || "/"; + const resolved = resolveRelayTarget(target, relayPath); + if (!resolved.ok) { + return new Response(resolved.reason, { status: resolved.status }); + } const headers = new Headers(req.headers); ["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach(h => headers.delete(h)); - const upstream = await fetch(target.replace(/\\/$/, "") + relayPath, { + const upstream = await fetch(resolved.url, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, @@ -75,6 +87,14 @@ export default async function handler(req) { }`; } +/** + * Test-only hook exposing the generated Vercel worker source so the SSRF + * regression test can assert it no longer string-concatenates the relay path + * and embeds the shared `resolveRelayTarget` guard. Not part of the route + * contract. + */ +export const __buildRelayFunctionForTest = buildRelayFunction; + async function pollDeployment(deploymentApiUrl: string, token: string): Promise<"READY" | "ERROR"> { for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) { await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); diff --git a/tests/unit/deno-deploy-relay-path-ssrf.test.ts b/tests/unit/deno-deploy-relay-path-ssrf.test.ts new file mode 100644 index 000000000000..830fa51e4459 --- /dev/null +++ b/tests/unit/deno-deploy-relay-path-ssrf.test.ts @@ -0,0 +1,104 @@ +// SSRF regression guard for the Deno Deploy relay worker (PR #4643 follow-up). +// +// The generated edge worker reads an attacker-controlled `x-relay-path` header +// and previously appended it to the (validated) `x-relay-target` origin via +// string concatenation: `fetch(target.replace(/\/$/, "") + relayPath)`. That +// lets the caller smuggle a NEW host past the allow/SSRF check using userinfo +// (`@`), a backslash, or a protocol-relative path: +// - x-relay-target: https://api.anthropic.com (passes the host guard) +// - x-relay-path: //evil.com/x → fetches https://evil.com/x +// - x-relay-path: /x@evil.com → userinfo confusion +// - x-relay-path: \evil.com → backslash host confusion +// +// The fix resolves the path against the validated target with `new URL()` and +// re-checks that the resolved host/credentials still match the target. The +// path-resolution decision is exported as the pure `resolveRelayTarget()` so it +// can be unit-tested directly (the worker body — a Deno-runtime string — embeds +// the SAME function source, asserted below by diffing the generated worker). +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + resolveRelayTarget, + __buildRelayWorkerForTest, +} from "../../src/app/api/settings/proxy/deno-deploy/route"; + +const TARGET = "https://api.anthropic.com"; + +describe("resolveRelayTarget — SSRF-safe path join", () => { + it("accepts a legitimate absolute path and keeps the validated host", () => { + const r = resolveRelayTarget(TARGET, "/v1/foo"); + assert.equal(r.ok, true, "a normal path must pass"); + if (r.ok) { + assert.equal(r.url, "https://api.anthropic.com/v1/foo"); + } + }); + + it("accepts a path with a query string", () => { + const r = resolveRelayTarget(TARGET, "/v1/messages?beta=true"); + assert.equal(r.ok, true); + if (r.ok) { + assert.equal(r.url, "https://api.anthropic.com/v1/messages?beta=true"); + } + }); + + it("rejects a protocol-relative path that swaps the host (//evil.com/x)", () => { + const r = resolveRelayTarget(TARGET, "//evil.com/x"); + assert.equal(r.ok, false, "//evil.com must NOT be allowed to change the host"); + if (!r.ok) assert.equal(r.status, 403); + }); + + it("rejects a path containing userinfo (/x@evil.com)", () => { + const r = resolveRelayTarget(TARGET, "/x@evil.com"); + assert.equal(r.ok, false, "@ in the path must be rejected"); + if (!r.ok) assert.equal(r.status, 403); + }); + + it("rejects a backslash host-confusion path (\\evil.com)", () => { + const r = resolveRelayTarget(TARGET, "\\evil.com"); + assert.equal(r.ok, false, "backslash must be rejected"); + if (!r.ok) assert.equal(r.status, 403); + }); + + it("rejects a value that does not start with '/'", () => { + const r = resolveRelayTarget(TARGET, "evil.com/x"); + assert.equal(r.ok, false, "a path must start with '/'"); + if (!r.ok) assert.equal(r.status, 403); + }); + + it("rejects an embedded-credentials path that resolves a different host", () => { + // Classic userinfo trick: everything before '@' becomes userinfo, the real + // host is after it. + const r = resolveRelayTarget(TARGET, "/@evil.com/path"); + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.status, 403); + }); + + it("rejects backslash-prefixed protocol-relative (\\\\evil.com)", () => { + const r = resolveRelayTarget(TARGET, "\\\\evil.com/x"); + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.status, 403); + }); +}); + +describe("buildRelayWorker — generated worker has no string-concat SSRF hole", () => { + it("does not concatenate the target with the raw relay path", () => { + const worker = __buildRelayWorkerForTest("deadbeefcafe"); + // The vulnerable pattern was: target.replace(/\/$/, "") + relayPath + assert.ok( + !/\.replace\(\s*\/\\?\/\$\/\s*,\s*["']{2}\s*\)\s*\+\s*relayPath/.test(worker), + "worker must not append relayPath to target by string concatenation" + ); + assert.ok( + !worker.includes('+ relayPath'), + "worker must not contain `+ relayPath` string concatenation" + ); + }); + + it("embeds the resolveRelayTarget guard into the worker body", () => { + const worker = __buildRelayWorkerForTest("deadbeefcafe"); + assert.ok( + worker.includes("resolveRelayTarget"), + "the worker must call the shared resolveRelayTarget guard" + ); + }); +}); diff --git a/tests/unit/vercel-deploy-relay-path-ssrf.test.ts b/tests/unit/vercel-deploy-relay-path-ssrf.test.ts new file mode 100644 index 000000000000..20716604a1be --- /dev/null +++ b/tests/unit/vercel-deploy-relay-path-ssrf.test.ts @@ -0,0 +1,87 @@ +// SSRF regression guard for the Vercel relay worker (lockstep with the Deno +// Deploy relay fix; PR #4643 / deno-deploy follow-up). +// +// The generated Vercel edge function reads an attacker-controlled `x-relay-path` +// header and previously appended it to the (validated) `x-relay-target` origin +// via string concatenation: `fetch(target.replace(/\/$/, "") + relayPath)`. +// That is bypassable via userinfo (`/x@evil.com`), a backslash (`\evil.com`), or +// a protocol-relative path (`//evil.com/x`) — the same hole the Deno worker had. +// +// The fix reuses the SAME pure `resolveRelayTarget()` guard (exported from the +// deno-deploy route, embedded into both workers via Function#toString). This +// test mirrors `deno-deploy-relay-path-ssrf.test.ts`: it asserts the generated +// Vercel worker no longer string-concatenates the path and embeds the guard. +// The pure-function behavior itself is covered once in the deno test (same +// function); here we focus on the Vercel worker wiring. +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { resolveRelayTarget } from "../../src/app/api/settings/proxy/deno-deploy/route"; +import { __buildRelayFunctionForTest } from "../../src/app/api/settings/proxy/vercel-deploy/route"; + +const TARGET = "https://api.anthropic.com"; + +describe("vercel relay reuses the SSRF-safe resolveRelayTarget guard", () => { + it("accepts a legitimate absolute path", () => { + const r = resolveRelayTarget(TARGET, "/v1/foo"); + assert.equal(r.ok, true); + if (r.ok) assert.equal(r.url, "https://api.anthropic.com/v1/foo"); + }); + + it("rejects //evil.com/x (host swap)", () => { + const r = resolveRelayTarget(TARGET, "//evil.com/x"); + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.status, 403); + }); + + it("rejects /x@evil.com (userinfo)", () => { + const r = resolveRelayTarget(TARGET, "/x@evil.com"); + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.status, 403); + }); + + it("rejects \\evil.com (backslash)", () => { + const r = resolveRelayTarget(TARGET, "\\evil.com"); + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.status, 403); + }); + + it("rejects a value without a leading '/'", () => { + const r = resolveRelayTarget(TARGET, "evil.com/x"); + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.status, 403); + }); +}); + +describe("buildRelayFunction — generated Vercel worker has no string-concat SSRF hole", () => { + it("does not append relayPath to target by string concatenation", () => { + const worker = __buildRelayFunctionForTest("deadbeefcafe"); + assert.ok( + !worker.includes("+ relayPath"), + "Vercel worker must not contain `+ relayPath` string concatenation" + ); + assert.ok( + !/\.replace\(\s*\/\\?\/\$\/\s*,\s*["']{2}\s*\)\s*\+\s*relayPath/.test(worker), + "Vercel worker must not append relayPath to target by string concatenation" + ); + }); + + it("embeds the resolveRelayTarget guard and fetches the resolved url", () => { + const worker = __buildRelayFunctionForTest("deadbeefcafe"); + assert.ok( + worker.includes("resolveRelayTarget"), + "the Vercel worker must call the shared resolveRelayTarget guard" + ); + assert.ok( + worker.includes("resolved.url"), + "the Vercel worker must fetch the SSRF-validated resolved url" + ); + }); + + it("still ships the edge runtime config marker", () => { + const worker = __buildRelayFunctionForTest("deadbeefcafe"); + assert.ok( + worker.includes('runtime: "edge"'), + "the Vercel edge runtime config must be preserved" + ); + }); +}); From 27c994ffd3a370a816df75f113c343fae83c0daf Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:04:20 -0300 Subject: [PATCH 062/120] =?UTF-8?q?feat(quota):=20recupera=C3=A7=C3=A3o=20?= =?UTF-8?q?proativa=20de=20conex=C3=B5es=20em=20cooldown=20(cron=20heal)?= =?UTF-8?q?=20[Fase=203=20#8]=20(#4900)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 --- .env.example | 10 + docs/reference/ENVIRONMENT.md | 2 + src/instrumentation-node.ts | 12 + src/lib/quota/connectionRecovery.ts | 303 +++++++++++++++++++ tests/unit/quota-connection-recovery.test.ts | 212 +++++++++++++ 5 files changed, 539 insertions(+) create mode 100644 src/lib/quota/connectionRecovery.ts create mode 100644 tests/unit/quota-connection-recovery.test.ts diff --git a/.env.example b/.env.example index c1a9d5ecce34..393f267015e2 100644 --- a/.env.example +++ b/.env.example @@ -551,6 +551,16 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500 # heuristic in instrumentation-node.ts. Default: unset (tests skip background). #OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS=1 +# Proactive connection-cooldown recovery (#8): re-validates connections whose +# transient `rate_limited_until` window has elapsed OUTSIDE the request hot path, +# so the first request after a cooldown does not pay the probe latency. Lazy +# recovery in getProviderCredentials still applies regardless. Used by: +# src/lib/quota/connectionRecovery.ts. +# Tick cadence (ms). Default 60000, floor 5000. +# OMNIROUTE_CONNECTION_RECOVERY_INTERVAL_MS=60000 +# Disable the proactive recovery scheduler entirely (default: false). +# OMNIROUTE_DISABLE_CONNECTION_RECOVERY=false + # Background job interval for budget reset checks (ms). Default: 600000 (10m). # Used by: src/lib/jobs/budgetResetJob.ts. Floor: 10000. #OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS=600000 diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index d9992880963c..734d33711ded 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -386,6 +386,8 @@ detection above). | `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). | diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 32debc0f3588..30f79f8f5931 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -276,6 +276,18 @@ export async function registerNodejs(): Promise { console.warn("[STARTUP] Auto-refresh daemon failed to start (non-fatal):", msg); } + // Proactive connection-cooldown recovery (#8): re-validate connections whose + // transient `rate_limited_until` window has elapsed OUTSIDE the request hot + // path, so the first request after a cooldown does not pay the probe latency. + // Lazy/self-recovery still happens in getProviderCredentials; this front-runs it. + try { + const { initConnectionRecoveryScheduler } = await import("@/lib/quota/connectionRecovery"); + initConnectionRecoveryScheduler(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[STARTUP] Connection recovery scheduler failed to start (non-fatal):", msg); + } + try { // Arena ELO sync: model intelligence from the Arena AI leaderboard, powering the // Free Provider Rankings page. On by default; configurable from Dashboard Feature Flags. diff --git a/src/lib/quota/connectionRecovery.ts b/src/lib/quota/connectionRecovery.ts new file mode 100644 index 000000000000..1e0e8d30343e --- /dev/null +++ b/src/lib/quota/connectionRecovery.ts @@ -0,0 +1,303 @@ +/** + * connectionRecovery.ts — Proactive recovery of provider connections whose + * transient cooldown has elapsed. + * + * Today the cooldown released by `markAccountUnavailable()` (testStatus + * 'unavailable' + a future `rateLimitedUntil`) is recovered LAZILY: a connection + * only becomes eligible again when the next real request reads it in + * `getProviderCredentials` (src/sse/services/auth.ts). The first request after + * the cooldown window therefore pays the latency of re-discovering a healthy + * connection. + * + * Modeled on gpt-load's CronChecker, this module identifies the subset of + * connections that are cooling down with an already-elapsed window and clears + * their error state OUTSIDE the request hot path, so they are restored before + * the next real request arrives. + * + * This file holds the PURE selection logic (`selectRecoverableConnections`, + * `isRecoverableCooldownConnection`) — no DB, no network, time injected — plus a + * thin async tick (`runConnectionRecoveryTick`) that wires the helper to the DB. + * The tick is NOT auto-started on import; the caller (startup bootstrap) decides + * when to schedule it, so importing this module in tests never spawns a timer. + */ + +import { cooldownUntilMs } from "@omniroute/open-sse/services/accountFallback.ts"; + +/** + * The transient-cooldown status written by `markAccountUnavailable()` for a + * recoverable failure. Only connections in this status are candidates for + * proactive recovery. + */ +export const RECOVERABLE_COOLDOWN_STATUS = "unavailable"; + +/** + * Terminal connection statuses that must NEVER be auto-recovered — they stay + * unavailable until credentials/settings change or an operator resets them. + * Mirrors `isTerminalConnectionStatus` (src/sse/services/auth.ts) and + * `TERMINAL_STATUSES` (src/lib/db/providers.ts::clearStaleCrashCooldowns). + */ +export const TERMINAL_CONNECTION_STATUSES = new Set([ + "banned", + "expired", + "credits_exhausted", +]); + +/** Minimal connection shape needed to decide recoverability. */ +export interface RecoverableConnectionInput { + id: string; + testStatus?: string | null; + rateLimitedUntil?: string | null; +} + +function normalizeStatus(value: string | null | undefined): string { + return (value || "").trim().toLowerCase(); +} + +/** + * True when `rateLimitedUntil` is set and its instant is at or before `nowMs` + * (the cooldown window has elapsed). Tolerates ISO strings and numeric-epoch + * strings — the `rate_limited_until` TEXT column can hold either (#3954). + */ +function hasElapsedCooldown(rateLimitedUntil: string | null | undefined, nowMs: number): boolean { + if (!rateLimitedUntil) return false; + const ms = cooldownUntilMs(rateLimitedUntil); + return Number.isFinite(ms) && ms <= nowMs; +} + +/** + * Decide whether a single connection is a proactive-recovery candidate: + * - has a real id, AND + * - testStatus === 'unavailable' (the transient cooldown status), AND + * - rateLimitedUntil is set and already in the past (< nowMs), AND + * - is NOT in a terminal state (banned / expired / credits_exhausted). + * + * Pure — `nowMs` is injected so callers/tests control the clock. + */ +export function isRecoverableCooldownConnection( + connection: RecoverableConnectionInput | null | undefined, + nowMs: number +): boolean { + if (!connection || typeof connection.id !== "string" || connection.id.length === 0) { + return false; + } + const status = normalizeStatus(connection.testStatus); + if (status !== RECOVERABLE_COOLDOWN_STATUS) return false; + if (TERMINAL_CONNECTION_STATUSES.has(status)) return false; // defensive; 'unavailable' is never terminal + return hasElapsedCooldown(connection.rateLimitedUntil, nowMs); +} + +/** + * From a list of connections, return only those whose transient cooldown has + * elapsed and are safe to restore. Pure, non-mutating, time injected. + */ +export function selectRecoverableConnections( + connections: readonly T[] | null | undefined, + nowMs: number +): T[] { + if (!Array.isArray(connections)) return []; + return connections.filter((connection) => isRecoverableCooldownConnection(connection, nowMs)); +} + +/** Result of one recovery tick (handy for logging / tests of the wiring). */ +export interface ConnectionRecoveryTickResult { + scanned: number; + recovered: number; + recoveredIds: string[]; +} + +/** + * Run one proactive-recovery pass: load active provider connections, select the + * subset whose transient cooldown has elapsed, and clear their error state via + * `clearAccountError` so they are eligible again before the next real request. + * + * Dependencies are injected (default to the real DB / auth modules) so the tick + * can be unit-tested without a live database. Best-effort and never throws — a + * failure to recover one connection must not abort the others or the scheduler. + * + * NOTE: not auto-started on import. The startup bootstrap is responsible for + * scheduling it (see runConnectionRecoveryTick usage in the report). + */ +export async function runConnectionRecoveryTick( + deps: { + nowMs?: number; + loadConnections?: () => Promise; + clearConnectionError?: ( + connectionId: string, + current: RecoverableConnectionInput + ) => Promise; + logger?: { info?: (msg: string) => void; warn?: (msg: string) => void }; + } = {} +): Promise { + const nowMs = deps.nowMs ?? Date.now(); + const result: ConnectionRecoveryTickResult = { scanned: 0, recovered: 0, recoveredIds: [] }; + + let connections: RecoverableConnectionInput[]; + try { + const load = + deps.loadConnections ?? + (async () => { + // Lazy import keeps this module loadable (and the pure helpers testable) + // without a full DB/auth graph. + const { getProviderConnections } = await import("@/lib/db/providers"); + const rows = (await getProviderConnections({ isActive: true })) as Array<{ + id?: unknown; + testStatus?: unknown; + rateLimitedUntil?: unknown; + }>; + return (Array.isArray(rows) ? rows : []).map((row) => ({ + id: typeof row.id === "string" ? row.id : "", + testStatus: typeof row.testStatus === "string" ? row.testStatus : null, + rateLimitedUntil: + typeof row.rateLimitedUntil === "string" ? row.rateLimitedUntil : null, + })); + }); + connections = await load(); + } catch (err) { + deps.logger?.warn?.( + `[ConnectionRecovery] failed to load connections: ${err instanceof Error ? err.message : String(err)}` + ); + return result; + } + + result.scanned = connections.length; + const recoverable = selectRecoverableConnections(connections, nowMs); + if (recoverable.length === 0) return result; + + const clear = + deps.clearConnectionError ?? + (async (connectionId: string, current: RecoverableConnectionInput) => { + const { clearAccountError } = await import("@/sse/services/auth"); + await clearAccountError(connectionId, current); + }); + + for (const connection of recoverable) { + try { + await clear(connection.id, connection); + result.recovered += 1; + result.recoveredIds.push(connection.id); + } catch (err) { + deps.logger?.warn?.( + `[ConnectionRecovery] failed to recover ${connection.id.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + if (result.recovered > 0) { + deps.logger?.info?.( + `[ConnectionRecovery] proactively restored ${result.recovered} connection(s) with elapsed cooldown` + ); + } + return result; +} + +// ── Scheduler (opt-out, low frequency) ────────────────────────────────────── +// Mirrors src/lib/tokenHealthCheck.ts: a globalThis-guarded singleton so HMR / +// double-import never stacks timers, an unref'd interval so it never holds the +// process open, and a self-disable in build/test processes. NOT auto-started on +// import — startup bootstrap calls initConnectionRecoveryScheduler(). + +const DEFAULT_TICK_MS = 60 * 1000; // re-validate elapsed cooldowns every 60s +const MIN_TICK_MS = 5 * 1000; // floor to avoid hot-looping if misconfigured +const RECOVERY_LOG_PREFIX = "[ConnectionRecovery]"; +const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]); + +declare global { + var __omnirouteConnRecovery: + | { initialized: boolean; interval: ReturnType | null } + | undefined; +} + +function getRecoveryState() { + if (!globalThis.__omnirouteConnRecovery) { + globalThis.__omnirouteConnRecovery = { initialized: false, interval: null }; + } + return globalThis.__omnirouteConnRecovery; +} + +function isEnvFlagEnabled(name: string): boolean { + const value = typeof process !== "undefined" ? process.env[name] : undefined; + return !!value && TRUE_ENV_VALUES.has(value.trim().toLowerCase()); +} + +function isBuildProcess(): boolean { + return typeof process !== "undefined" && process.env.NEXT_PHASE === "phase-production-build"; +} + +function isAutomatedTestProcess(): boolean { + return ( + typeof process !== "undefined" && + (process.env.NODE_ENV === "test" || + process.env.VITEST !== undefined || + process.argv.some((arg) => arg.includes("test"))) + ); +} + +function isRecoverySchedulerDisabled(): boolean { + return ( + isEnvFlagEnabled("OMNIROUTE_DISABLE_CONNECTION_RECOVERY") || + isEnvFlagEnabled("OMNIROUTE_DISABLE_BACKGROUND_SERVICES") || + isBuildProcess() || + isAutomatedTestProcess() + ); +} + +/** + * Resolve the tick interval (ms) from OMNIROUTE_CONNECTION_RECOVERY_INTERVAL_MS, + * falling back to the 60s default and clamping to a small floor. + */ +export function resolveConnectionRecoveryIntervalMs( + rawValue: string | undefined = typeof process !== "undefined" + ? process.env.OMNIROUTE_CONNECTION_RECOVERY_INTERVAL_MS + : undefined +): number { + if (!rawValue) return DEFAULT_TICK_MS; + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_TICK_MS; + return Math.max(MIN_TICK_MS, Math.floor(parsed)); +} + +/** + * Start the proactive connection-recovery scheduler (idempotent). No-op in + * build/test processes or when disabled via env. Each tick runs + * runConnectionRecoveryTick() against the real DB. + */ +export function initConnectionRecoveryScheduler(): void { + const state = getRecoveryState(); + if (state.initialized || isRecoverySchedulerDisabled()) return; + state.initialized = true; + + const tickMs = resolveConnectionRecoveryIntervalMs(); + const tickLogger = { + info: (msg: string) => console.log(msg), + warn: (msg: string) => console.warn(msg), + }; + + const runTick = () => { + runConnectionRecoveryTick({ logger: tickLogger }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`${RECOVERY_LOG_PREFIX} tick error (non-fatal): ${msg}`); + }); + }; + + console.log( + `${RECOVERY_LOG_PREFIX} Starting proactive cooldown recovery (tick every ${Math.round(tickMs / 1000)}s)` + ); + + // Delay the first tick a little so it never piles onto cold-start work. + const timer = setTimeout(() => { + runTick(); + state.interval = setInterval(runTick, tickMs); + (state.interval as { unref?: () => void } | undefined)?.unref?.(); + }, 15_000); + (timer as { unref?: () => void } | undefined)?.unref?.(); +} + +/** Stop the scheduler (tests / hot-reload). */ +export function stopConnectionRecoveryScheduler(): void { + const state = getRecoveryState(); + if (state.interval) { + clearInterval(state.interval); + state.interval = null; + } + state.initialized = false; +} diff --git a/tests/unit/quota-connection-recovery.test.ts b/tests/unit/quota-connection-recovery.test.ts new file mode 100644 index 000000000000..24120ab1ac66 --- /dev/null +++ b/tests/unit/quota-connection-recovery.test.ts @@ -0,0 +1,212 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + isRecoverableCooldownConnection, + resolveConnectionRecoveryIntervalMs, + runConnectionRecoveryTick, + selectRecoverableConnections, + type RecoverableConnectionInput, +} from "../../src/lib/quota/connectionRecovery.ts"; + +const NOW = Date.UTC(2026, 5, 23, 12, 0, 0); // fixed clock for deterministic tests +const PAST = new Date(NOW - 60_000).toISOString(); // 60s in the past → cooldown elapsed +const FUTURE = new Date(NOW + 60_000).toISOString(); // 60s in the future → still cooling + +function conn(overrides: Partial): RecoverableConnectionInput { + // Use the `in` operator so an EXPLICIT null/undefined override is honored + // (??/|| would collapse it back to the default and hide the no-cooldown case). + return { + id: "id" in overrides ? (overrides.id as string) : "c1", + testStatus: "testStatus" in overrides ? overrides.testStatus : "unavailable", + rateLimitedUntil: "rateLimitedUntil" in overrides ? overrides.rateLimitedUntil : PAST, + }; +} + +test("isRecoverableCooldownConnection: unavailable + elapsed cooldown → recoverable", () => { + assert.equal( + isRecoverableCooldownConnection(conn({ testStatus: "unavailable", rateLimitedUntil: PAST }), NOW), + true + ); +}); + +test("isRecoverableCooldownConnection: cooldown still in the future → NOT recoverable", () => { + assert.equal( + isRecoverableCooldownConnection( + conn({ testStatus: "unavailable", rateLimitedUntil: FUTURE }), + NOW + ), + false + ); +}); + +test("isRecoverableCooldownConnection: terminal states are never recovered", () => { + for (const status of ["banned", "expired", "credits_exhausted"]) { + assert.equal( + isRecoverableCooldownConnection(conn({ testStatus: status, rateLimitedUntil: PAST }), NOW), + false, + `${status} must not be recoverable` + ); + } +}); + +test("isRecoverableCooldownConnection: terminal-status matching is case/space insensitive", () => { + assert.equal( + isRecoverableCooldownConnection(conn({ testStatus: " Banned ", rateLimitedUntil: PAST }), NOW), + false + ); +}); + +test("isRecoverableCooldownConnection: no rateLimitedUntil → NOT recoverable", () => { + assert.equal( + isRecoverableCooldownConnection(conn({ testStatus: "unavailable", rateLimitedUntil: null }), NOW), + false + ); + assert.equal( + isRecoverableCooldownConnection( + conn({ testStatus: "unavailable", rateLimitedUntil: undefined }), + NOW + ), + false + ); +}); + +test("isRecoverableCooldownConnection: status other than 'unavailable' is left alone", () => { + // Only the transient-cooldown status should be proactively restored. An + // 'active' or null status with a stale rateLimitedUntil is not this job's + // concern (the lazy backoff-decay path already handles active rows). + assert.equal( + isRecoverableCooldownConnection(conn({ testStatus: "active", rateLimitedUntil: PAST }), NOW), + false + ); + assert.equal( + isRecoverableCooldownConnection(conn({ testStatus: null, rateLimitedUntil: PAST }), NOW), + false + ); +}); + +test("isRecoverableCooldownConnection: missing connection id → NOT recoverable", () => { + assert.equal( + isRecoverableCooldownConnection(conn({ id: "", rateLimitedUntil: PAST }), NOW), + false + ); +}); + +test("isRecoverableCooldownConnection: numeric-epoch rateLimitedUntil string is tolerated", () => { + // The rate_limited_until TEXT column can hold a numeric epoch string (#3954). + assert.equal( + isRecoverableCooldownConnection( + conn({ testStatus: "unavailable", rateLimitedUntil: String(NOW - 1_000) }), + NOW + ), + true + ); + assert.equal( + isRecoverableCooldownConnection( + conn({ testStatus: "unavailable", rateLimitedUntil: String(NOW + 1_000) }), + NOW + ), + false + ); +}); + +test("selectRecoverableConnections returns only the elapsed-cooldown unavailable rows", () => { + const connections: RecoverableConnectionInput[] = [ + conn({ id: "elapsed", testStatus: "unavailable", rateLimitedUntil: PAST }), + conn({ id: "still-cooling", testStatus: "unavailable", rateLimitedUntil: FUTURE }), + conn({ id: "banned", testStatus: "banned", rateLimitedUntil: PAST }), + conn({ id: "expired", testStatus: "expired", rateLimitedUntil: PAST }), + conn({ id: "credits", testStatus: "credits_exhausted", rateLimitedUntil: PAST }), + conn({ id: "no-cooldown", testStatus: "unavailable", rateLimitedUntil: null }), + conn({ id: "active", testStatus: "active", rateLimitedUntil: PAST }), + ]; + + const recoverable = selectRecoverableConnections(connections, NOW); + assert.deepEqual( + recoverable.map((c) => c.id), + ["elapsed"] + ); +}); + +test("selectRecoverableConnections returns [] for empty / non-array input", () => { + assert.deepEqual(selectRecoverableConnections([], NOW), []); + assert.deepEqual( + selectRecoverableConnections(undefined as unknown as RecoverableConnectionInput[], NOW), + [] + ); +}); + +test("selectRecoverableConnections does not mutate the input array", () => { + const connections: RecoverableConnectionInput[] = [ + conn({ id: "a", rateLimitedUntil: PAST }), + conn({ id: "b", rateLimitedUntil: FUTURE }), + ]; + const before = connections.length; + selectRecoverableConnections(connections, NOW); + assert.equal(connections.length, before); +}); + +test("runConnectionRecoveryTick clears only the elapsed-cooldown connections (injected deps, no DB)", async () => { + const cleared: string[] = []; + const result = await runConnectionRecoveryTick({ + nowMs: NOW, + loadConnections: async () => [ + conn({ id: "elapsed", testStatus: "unavailable", rateLimitedUntil: PAST }), + conn({ id: "still-cooling", testStatus: "unavailable", rateLimitedUntil: FUTURE }), + conn({ id: "banned", testStatus: "banned", rateLimitedUntil: PAST }), + conn({ id: "active", testStatus: "active", rateLimitedUntil: PAST }), + ], + clearConnectionError: async (connectionId) => { + cleared.push(connectionId); + }, + }); + + assert.deepEqual(cleared, ["elapsed"]); + assert.equal(result.scanned, 4); + assert.equal(result.recovered, 1); + assert.deepEqual(result.recoveredIds, ["elapsed"]); +}); + +test("runConnectionRecoveryTick isolates a per-connection clear failure (others still recovered)", async () => { + const cleared: string[] = []; + const warnings: string[] = []; + const result = await runConnectionRecoveryTick({ + nowMs: NOW, + loadConnections: async () => [ + conn({ id: "boom", testStatus: "unavailable", rateLimitedUntil: PAST }), + conn({ id: "ok", testStatus: "unavailable", rateLimitedUntil: PAST }), + ], + clearConnectionError: async (connectionId) => { + if (connectionId === "boom") throw new Error("db write failed"); + cleared.push(connectionId); + }, + logger: { warn: (m) => warnings.push(m) }, + }); + + assert.deepEqual(cleared, ["ok"]); + assert.equal(result.recovered, 1); + assert.equal(warnings.length, 1); +}); + +test("runConnectionRecoveryTick returns a zero result and never throws when loading fails", async () => { + const result = await runConnectionRecoveryTick({ + nowMs: NOW, + loadConnections: async () => { + throw new Error("DB unavailable"); + }, + clearConnectionError: async () => { + throw new Error("must not be called"); + }, + }); + assert.deepEqual(result, { scanned: 0, recovered: 0, recoveredIds: [] }); +}); + +test("resolveConnectionRecoveryIntervalMs defaults to 60s and clamps to a floor", () => { + assert.equal(resolveConnectionRecoveryIntervalMs(undefined), 60_000); + assert.equal(resolveConnectionRecoveryIntervalMs(""), 60_000); + assert.equal(resolveConnectionRecoveryIntervalMs("not-a-number"), 60_000); + assert.equal(resolveConnectionRecoveryIntervalMs("0"), 60_000); + assert.equal(resolveConnectionRecoveryIntervalMs("-5"), 60_000); + assert.equal(resolveConnectionRecoveryIntervalMs("120000"), 120_000); + assert.equal(resolveConnectionRecoveryIntervalMs("1000"), 5_000); // clamped up to MIN_TICK_MS +}); From 4305ef8690e2ccc24c5feab276470f1f52a80ddb Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:04:22 -0300 Subject: [PATCH 063/120] =?UTF-8?q?fix(quota):=20policy=20inv=C3=A1lida=20?= =?UTF-8?q?n=C3=A3o=20vaza=20allow=20+=20guard=20connectionIds=20vazio=20[?= =?UTF-8?q?Fase=203=20#10]=20(#4901)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 --- src/lib/db/quotaPools.ts | 16 ++++- src/lib/quota/fairShare.ts | 26 ++++++++- tests/unit/db-quota-pools.test.ts | 59 +++++++++++++++++++ tests/unit/quota-combos-sync.test.ts | 87 ++++++++++++++++++++++++++++ tests/unit/quota-fair-share.test.ts | 46 +++++++++++++++ 5 files changed, 230 insertions(+), 4 deletions(-) diff --git a/src/lib/db/quotaPools.ts b/src/lib/db/quotaPools.ts index 14b9032e3ad8..93cf86264d4c 100644 --- a/src/lib/db/quotaPools.ts +++ b/src/lib/db/quotaPools.ts @@ -144,11 +144,25 @@ interface AllocationRow { policy: string; } +const VALID_POLICIES: ReadonlySet = new Set(["hard", "soft", "burst"]); + +/** + * Fail-safe policy normalization at the DB read boundary. A column value outside + * `hard | soft | burst` (corrupted/legacy row, or a value inserted while the + * table CHECK constraint was bypassed) is coerced to the most restrictive + * policy, `hard`, instead of being trusted via a raw `as Policy` cast. This + * prevents an unknown policy from reaching the fair-share engine, where it would + * otherwise be a silent fail-OPEN (issue #10). + */ +function normalizePolicy(value: string): Policy { + return VALID_POLICIES.has(value) ? (value as Policy) : "hard"; +} + function rowToAllocation(row: AllocationRow): PoolAllocation { const alloc: PoolAllocation = { apiKeyId: row.api_key_id, weight: row.weight, - policy: row.policy as Policy, + policy: normalizePolicy(row.policy), }; if (row.cap_value != null) alloc.capValue = row.cap_value; if (row.cap_unit != null) alloc.capUnit = row.cap_unit as QuotaUnit; diff --git a/src/lib/quota/fairShare.ts b/src/lib/quota/fairShare.ts index 4da7fcb3fa17..238dea45d06a 100644 --- a/src/lib/quota/fairShare.ts +++ b/src/lib/quota/fairShare.ts @@ -61,6 +61,19 @@ function dimensionKeyString(key: FairShareDimension["key"]): string { return `${key.poolId}:${key.unit}:${key.window}`; } +const KNOWN_POLICIES: ReadonlySet = new Set(["hard", "soft", "burst"]); + +/** + * Fail-safe policy normalization. Any value outside the known + * `hard | soft | burst` set (e.g. a corrupted DB row that reached this engine + * through an unchecked `row.policy as Policy` cast) is treated as the most + * restrictive policy, `hard`. This closes a fail-OPEN hole: an unknown policy + * used to fall through every `switch` case and return a silent `allow`. + */ +function normalizePolicy(policy: Policy): Policy { + return KNOWN_POLICIES.has(policy) ? policy : "hard"; +} + // --------------------------------------------------------------------------- // Core algorithm // --------------------------------------------------------------------------- @@ -77,6 +90,10 @@ export function decideFairShare(input: FairShareInput): FairShareDecision { return { kind: "allow", reason: "ok" }; } + // Fail-safe: an unknown/corrupted policy is treated as `hard` (most + // restrictive) so it can never silently bypass fair-share enforcement. + const effectivePolicy = normalizePolicy(allocation.policy); + let anyPenalized = false; for (const dim of dimensions) { @@ -97,7 +114,7 @@ export function decideFairShare(input: FairShareInput): FairShareDecision { // If the pool's global limit is already reached AND this key's request // would exceed it (burst mode without borrow room), block as "global-saturated". if (dim.consumedTotal >= dim.limit) { - if (allocation.policy !== "burst") { + if (effectivePolicy !== "burst") { return { kind: "block", reason: "global-saturated" }; } // burst also blocked when no room at all @@ -108,7 +125,9 @@ export function decideFairShare(input: FairShareInput): FairShareDecision { if (isStrict) { // ── Strict mode ──────────────────────────────────────────────────── - switch (allocation.policy) { + // effectivePolicy is normalized (unknown → hard), so these cases are + // exhaustive and an unknown policy is enforced as hard. + switch (effectivePolicy) { case "hard": // Hard: block once consumed >= fair_share if (consumed >= fairShare) { @@ -131,7 +150,8 @@ export function decideFairShare(input: FairShareInput): FairShareDecision { } else { // ── Generous mode ────────────────────────────────────────────────── // There is slack — allow borrowing up to the global limit. - switch (allocation.policy) { + // effectivePolicy is normalized (unknown → hard). + switch (effectivePolicy) { case "hard": // Hard in generous mode: allow if global limit not reached AND // the key is within global limit (which we know because diff --git a/tests/unit/db-quota-pools.test.ts b/tests/unit/db-quota-pools.test.ts index 12575f2e24bf..670ab67f8f0f 100644 --- a/tests/unit/db-quota-pools.test.ts +++ b/tests/unit/db-quota-pools.test.ts @@ -20,6 +20,7 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const poolsDb = await import("../../src/lib/db/quotaPools.ts"); +const { getDbInstance } = core; async function resetStorage() { core.resetDbInstance(); @@ -260,3 +261,61 @@ test("allocation stores optional capValue and capUnit correctly", () => { assert.equal(alloc.capValue, 1000); assert.equal(alloc.capUnit, "requests"); }); + +// --------------------------------------------------------------------------- +// Guard A (issue #10): corrupted/unknown policy in the DB must be normalized to +// the most restrictive policy ('hard') at the read boundary — never trusted via +// `row.policy as Policy`. A garbage policy reaching the fair-share engine would +// fall through every switch case and silently ALLOW (fail-OPEN). +// +// The schema has CHECK (policy IN ('hard','soft','burst')), so we bypass it with +// PRAGMA ignore_check_constraints to simulate a legacy/corrupted row. +// --------------------------------------------------------------------------- + +test("rowToAllocation normalizes an unknown DB policy to 'hard' (Guard A)", () => { + const pool = poolsDb.createPool({ + connectionId: "c-guardA", + name: "Corrupt Policy Pool", + allocations: [{ apiKeyId: "k-corrupt", weight: 100, policy: "soft" }], + }); + + // Inject a corrupted policy directly, bypassing the CHECK constraint. + const db = getDbInstance() as unknown as { + pragma: (s: string) => unknown; + prepare: (sql: string) => { run: (...p: unknown[]) => unknown }; + }; + db.pragma("ignore_check_constraints = ON"); + db.prepare("UPDATE quota_allocations SET policy = ? WHERE pool_id = ? AND api_key_id = ?").run( + "bogus-policy", + pool.id, + "k-corrupt" + ); + db.pragma("ignore_check_constraints = OFF"); + + // Read through the domain module — the unknown policy must become 'hard'. + const found = poolsDb.getPool(pool.id)!; + const alloc = found.allocations.find((a) => a.apiKeyId === "k-corrupt")!; + assert.equal(alloc.policy, "hard", "unknown DB policy must be normalized to 'hard'"); + + // Same expectation via listAllocationsForApiKey (the other read path). + const list = poolsDb.listAllocationsForApiKey("k-corrupt"); + assert.equal(list.length, 1); + assert.equal(list[0].allocation.policy, "hard"); +}); + +test("rowToAllocation preserves valid policies unchanged (Guard A regression)", () => { + const pool = poolsDb.createPool({ + connectionId: "c-guardA-valid", + name: "Valid Policy Pool", + allocations: [ + { apiKeyId: "k-hard", weight: 34, policy: "hard" }, + { apiKeyId: "k-soft", weight: 33, policy: "soft" }, + { apiKeyId: "k-burst", weight: 33, policy: "burst" }, + ], + }); + + const found = poolsDb.getPool(pool.id)!; + assert.equal(found.allocations.find((a) => a.apiKeyId === "k-hard")!.policy, "hard"); + assert.equal(found.allocations.find((a) => a.apiKeyId === "k-soft")!.policy, "soft"); + assert.equal(found.allocations.find((a) => a.apiKeyId === "k-burst")!.policy, "burst"); +}); diff --git a/tests/unit/quota-combos-sync.test.ts b/tests/unit/quota-combos-sync.test.ts index 0d3c74c0f6e4..a0a1b5b50261 100644 --- a/tests/unit/quota-combos-sync.test.ts +++ b/tests/unit/quota-combos-sync.test.ts @@ -332,3 +332,90 @@ test("removeQuotaCombosForPool: unknown pool id — no throw", async () => { "removeQuotaCombosForPool with unknown poolId should not throw" ); }); + +// --------------------------------------------------------------------------- +// Guard B (issue #10): a pool whose connections no longer resolve (empty/dangling +// connection list) must NOT prune/delete the group's existing combos. Pruning is +// provider-scoped, and with no resolvable connection the provider is unknown — so +// syncQuotaCombos returns early (poolProvider === undefined) BEFORE the prune loop. +// This proves the `if (!poolProvider) return` guard covers the empty-connection +// path: a transient connection-resolution failure cannot wipe a group's combos. +// --------------------------------------------------------------------------- + +test("syncQuotaCombos: pool with no resolvable connection does NOT prune existing combos (Guard B)", async () => { + // 1. Seed a glm connection + pool and mint combos. + const conn = await providersDb.createProviderConnection({ + provider: "glm", + authType: "apikey", + name: "quota-combos-guardB", + apiKey: "sk-test-glm-guardb", + }); + const connId = (conn as Record).id as string; + const pool = poolsDb.createPool({ connectionId: connId, name: "GuardBPool" }); + + await syncQuotaCombos(pool.id); + const before = await listQuotaCombos(); + assert.ok(before.length > 0, "expected combos after initial sync"); + const beforeNames = new Set(before.map((c) => c.name)); + + // 2. Delete the provider connection while the pool still references it. + // The join row (quota_pool_connections) remains → getConnectionIds returns a + // dangling id whose getProviderConnectionById() resolves to null. This is the + // "connections do not resolve" scenario. + const removed = await providersDb.deleteProviderConnection(connId); + assert.equal(removed, true, "connection should be deleted"); + assert.equal( + await providersDb.getProviderConnectionById(connId), + null, + "connection must no longer resolve" + ); + + // 3. Re-sync. With no resolvable connection, poolProvider is undefined → the + // guard returns before pruning. Existing combos must be untouched. + await syncQuotaCombos(pool.id); + + const after = await listQuotaCombos(); + assert.equal( + after.length, + before.length, + "combos must NOT be pruned when the pool has no resolvable connection" + ); + for (const name of beforeNames) { + const stillThere = after.some((c) => c.name === name); + assert.ok(stillThere, `combo was wrongly pruned: ${name}`); + } +}); + +test("syncQuotaCombos: pool whose join table is emptied (truly no connectionIds) does NOT prune combos (Guard B)", async () => { + // Variant of Guard B where the join table itself is empty AND the primary + // connection is gone — connectionIds falls back to [pool.connectionId], which + // also fails to resolve. Still must not prune. + const conn = await providersDb.createProviderConnection({ + provider: "glm", + authType: "apikey", + name: "quota-combos-guardB-empty", + apiKey: "sk-test-glm-guardb-empty", + }); + const connId = (conn as Record).id as string; + const pool = poolsDb.createPool({ connectionId: connId, name: "GuardBEmptyPool" }); + + await syncQuotaCombos(pool.id); + const before = await listQuotaCombos(); + assert.ok(before.length > 0, "expected combos after initial sync"); + + // Empty the join table for this pool AND delete the connection row. + const db = core.getDbInstance() as unknown as { + prepare: (sql: string) => { run: (...p: unknown[]) => unknown }; + }; + db.prepare("DELETE FROM quota_pool_connections WHERE pool_id = ?").run(pool.id); + await providersDb.deleteProviderConnection(connId); + + await syncQuotaCombos(pool.id); + + const after = await listQuotaCombos(); + assert.equal( + after.length, + before.length, + "combos must survive when the pool has an empty/unresolvable connection set" + ); +}); diff --git a/tests/unit/quota-fair-share.test.ts b/tests/unit/quota-fair-share.test.ts index 37efde61b7ed..904dd3a8596d 100644 --- a/tests/unit/quota-fair-share.test.ts +++ b/tests/unit/quota-fair-share.test.ts @@ -201,3 +201,49 @@ test("fairShare: empty dimensions → allow:ok", () => { assert.equal(result.kind, "allow"); assert.equal(result.reason, "ok"); }); + +// ─── Guard A: unknown/garbage policy must fail SAFE (treated as hard) ───────── +// Defensive guard for issue #10: a policy value outside hard|soft|burst (e.g. a +// corrupted DB row read through `row.policy as Policy`) previously fell through +// every switch case in decideFairShare and returned a silent `allow` +// (fail-OPEN). It must be treated as the most restrictive policy (hard) so an +// unknown policy can never bypass fair-share enforcement. + +test("fairShare: GUARD-A strict mode, unknown policy over fair_share → block:fair-share (treated as hard)", () => { + // globalUsedPercent=0.6 >= 0.5 → strict; consumed=600 > fair_share=500. + // policy is garbage ("bogus") — must behave like hard and BLOCK, not allow. + const result = decideFairShare({ + dimensions: [dim({ limit: 1000, consumedTotal: 700, globalUsedPercent: 0.6 })], + // Cast through unknown to bypass the Policy type — simulates a corrupted row. + allocation: { weight: 50, policy: "bogus" as unknown as "hard" }, + consumedByThisKey: { "pool1:tokens:hourly": 600 }, + saturationThreshold: THRESHOLD, + }); + assert.equal(result.kind, "block"); + assert.equal(result.reason, "fair-share"); +}); + +test("fairShare: GUARD-A strict mode, unknown policy UNDER fair_share → allow (hard semantics still allow under share)", () => { + // Treated as hard: under fair_share is allowed even in strict mode. + const result = decideFairShare({ + dimensions: [dim({ limit: 1000, consumedTotal: 700, globalUsedPercent: 0.7 })], + allocation: { weight: 50, policy: "garbage" as unknown as "hard" }, + consumedByThisKey: { "pool1:tokens:hourly": 300 }, + saturationThreshold: THRESHOLD, + }); + assert.equal(result.kind, "allow"); +}); + +test("fairShare: GUARD-A generous mode, unknown policy → hard semantics (no soft penalize / no burst leniency)", () => { + // Generous mode (globalUsedPercent=0.3), consumed=600 > fair_share=500, but + // consumedTotal=600 < 1000 so hard allows borrowing. Must NOT be flagged as + // penalized (that would be soft) and must NOT block (key < global limit). + const result = decideFairShare({ + dimensions: [dim({ limit: 1000, consumedTotal: 600, globalUsedPercent: 0.3 })], + allocation: { weight: 50, policy: "weird" as unknown as "hard" }, + consumedByThisKey: { "pool1:tokens:hourly": 600 }, + saturationThreshold: THRESHOLD, + }); + assert.equal(result.kind, "allow"); + assert.equal(result.penalized, undefined, "unknown policy must not get soft penalize semantics"); +}); From 70afa2b5c6450ad2e92e58126e42a4060755a3a7 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:04:37 -0300 Subject: [PATCH 064/120] =?UTF-8?q?feat(quota):=20satura=C3=A7=C3=A3o=20re?= =?UTF-8?q?al=20do=20Claude=20no=20fair-share=20via=20/api/oauth/usage=20(?= =?UTF-8?q?#4885)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 --- src/lib/quota/saturationSignals.ts | 139 ++++++++++++- .../quota-saturation-anthropic-oauth.test.ts | 188 ++++++++++++++++++ 2 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 tests/unit/quota-saturation-anthropic-oauth.test.ts diff --git a/src/lib/quota/saturationSignals.ts b/src/lib/quota/saturationSignals.ts index c045288707a5..5082552aaea6 100644 --- a/src/lib/quota/saturationSignals.ts +++ b/src/lib/quota/saturationSignals.ts @@ -3,11 +3,20 @@ * for a provider/connection/dimension combination. * * Strategy (per provider): - * codex → codexQuotaFetcher (dual 5h + weekly window) - * bailian → bailianQuotaFetcher (triple 5h + weekly + monthly window) - * default → getUsageForProvider (open-sse/services/usage.ts) + * codex → codexQuotaFetcher (dual 5h + weekly window) + * bailian → bailianQuotaFetcher (triple 5h + weekly + monthly window) + * anthropic/claude → REAL plan-window utilization from GET /api/oauth/usage + * (the same path usage.ts already uses): window "5h" → + * five_hour.utilization, "weekly" → seven_day.utilization. + * Falls back to the per-minute REQUEST rate-limit headers + * (anthropic-ratelimit-requests-*) only when no OAuth plan + * window is available (e.g. API-key Claude connections). + * default → getUsageForProvider (open-sse/services/usage.ts) * - * Cache: in-memory Map, TTL = 30 seconds. + * Cache: in-memory Map, TTL = 30 seconds. The 30s TTL is what keeps the + * NON-OFFICIAL, rate-limited oauth/usage endpoint from being polled per + * request (it returns 429 under load) — never call it on the hot path without + * this cache. usage.ts adds its own 429 cooldown on top. * Fail-open: on any error, return 0 (generous mode) and log pino.warn. * Hard Rule #12: no stack traces propagated to return values. * @@ -158,10 +167,13 @@ async function fetchBailianSaturation( return Math.min(1, Math.max(0, pct)); } -async function fetchAnthropicSaturation( - connectionId: string, - dim: DimensionSpec -): Promise { +/** + * Per-minute REQUEST rate-limit headers fallback. Used only when the OAuth + * plan-window utilization is unavailable (e.g. API-key Claude connections that + * have no /api/oauth/usage data). This signal reflects TPM/RPM bursts, NOT the + * 5h/weekly plan window, so it is a weak last resort. + */ +function anthropicHeaderSaturation(connectionId: string): number { const entry = _rateLimitHeaders.get(`anthropic:${connectionId}`); if (!entry || Date.now() - entry.ts > RL_HEADER_TTL_MS) return 0; @@ -169,6 +181,117 @@ async function fetchAnthropicSaturation( return Math.min(1, Math.max(0, used / entry.limit)); } +/** + * Injectable seam (DB lookup + usage fetch) so the oauth/usage plan-window path + * is unit-testable without touching the DB or the network. Defaults are wired + * lazily to the real implementations inside fetchAnthropicSaturation. + */ +interface AnthropicSaturationDeps { + /** Resolve a connection (with decrypted accessToken/authType) by id. */ + loadConnection: (connectionId: string) => Promise | null>; + /** Fetch usage for the connection (delegates to getUsageForProvider). */ + fetchUsage: (conn: Record) => Promise; +} + +let _anthropicDepsOverride: AnthropicSaturationDeps | null = null; + +/** Test-only: inject ({loadConnection, fetchUsage}); pass null to restore. */ +export function __setAnthropicSaturationDepsForTests( + deps: AnthropicSaturationDeps | null +): void { + _anthropicDepsOverride = deps; +} + +async function defaultAnthropicDeps(): Promise { + const [providersMod, usageMod] = await Promise.all([ + import("@/lib/db/providers"), + import("@omniroute/open-sse/services/usage"), + ]); + return { + loadConnection: (connectionId) => + providersMod.getProviderConnectionById(connectionId) as Promise | null>, + fetchUsage: (conn) => + usageMod.getUsageForProvider(conn as Parameters[0]), + }; +} + +/** + * Map a QuotaWindow to the Claude usage quota key produced by getClaudeUsage + * (usage.ts). "session (5h)" carries five_hour.utilization and "weekly (7d)" + * carries seven_day.utilization. + */ +function claudeUsageKeyForWindow(window: QuotaWindow): string | null { + switch (window) { + case "5h": + return "session (5h)"; + case "weekly": + case "monthly": + // Anthropic exposes a 7-day plan window, not a monthly one — treat the + // longer requested window as the weekly plan saturation. + return "weekly (7d)"; + default: + return null; + } +} + +/** + * Extract the plan utilization (0..1) for the requested window from a + * getClaudeUsage() result, or null when the OAuth plan window is unavailable + * (e.g. legacy/admin API-key shape with no per-window quotas). + */ +function planUtilizationFromUsage(usage: unknown, window: QuotaWindow): number | null { + if (!usage || typeof usage !== "object") return null; + const quotas = (usage as Record).quotas; + if (!quotas || typeof quotas !== "object" || Array.isArray(quotas)) return null; + + const key = claudeUsageKeyForWindow(window); + if (!key) return null; + const entry = (quotas as Record)[key]; + if (!entry || typeof entry !== "object") return null; + + // getClaudeUsage stores `used` = utilization (% used, 0..100). + const used = (entry as Record).used; + if (typeof used !== "number" || !Number.isFinite(used)) return null; + return Math.min(1, Math.max(0, used / 100)); +} + +async function fetchAnthropicSaturation( + connectionId: string, + dim: DimensionSpec +): Promise { + // Try the REAL plan-window utilization first (5h / weekly), via the same + // /api/oauth/usage path usage.ts already uses. This is the signal fairShare + // actually needs for Claude Pro/Max — the per-minute request headers do not + // reflect the plan window. Any failure here falls back to the header path, + // and ultimately fails open (0). + try { + const deps = _anthropicDepsOverride ?? (await defaultAnthropicDeps()); + const conn = await deps.loadConnection(connectionId); + // Only OAuth connections have plan-window usage; API-key Claude does not. + const hasOauthToken = + !!conn && + typeof conn.accessToken === "string" && + conn.accessToken.length > 0 && + (conn.authType === undefined || conn.authType === "oauth"); + if (hasOauthToken) { + const usage = await deps.fetchUsage(conn as Record); + const util = planUtilizationFromUsage(usage, dim.window); + if (util !== null) return util; + } + } catch (err) { + log.warn( + { err: (err as Error)?.message, connectionId }, + "anthropic oauth/usage saturation failed — falling back to rate-limit headers" + ); + } + + // Fallback: per-minute REQUEST rate-limit headers (weak, TPM/RPM only). + return anthropicHeaderSaturation(connectionId); +} + async function fetchGenericSaturation( connectionId: string, provider: string diff --git a/tests/unit/quota-saturation-anthropic-oauth.test.ts b/tests/unit/quota-saturation-anthropic-oauth.test.ts new file mode 100644 index 000000000000..05deab4602ad --- /dev/null +++ b/tests/unit/quota-saturation-anthropic-oauth.test.ts @@ -0,0 +1,188 @@ +/** + * tests/unit/quota-saturation-anthropic-oauth.test.ts + * + * #1 — Real plan-window saturation for Claude/Anthropic fair-share. + * + * Before this change, the anthropic/claude branch of getSaturation only read + * the per-minute REQUEST rate-limit headers (anthropic-ratelimit-requests-*), + * which do NOT reflect the Claude Pro/Max 5h / weekly plan window. fairShare + * was therefore nearly blind for Claude. + * + * This suite asserts the branch now uses the REAL plan utilization that + * usage.ts already fetches from GET /api/oauth/usage (five_hour.utilization / + * seven_day.utilization), window-aware: + * - dim.window "5h" → five_hour.utilization / 100 + * - dim.window "weekly" → seven_day.utilization / 100 + * + * The oauth/usage path and the DB connection lookup are injected (deps seam) + * so the test never touches the network or the DB. The 30s saturation cache is + * cleared between asserts via _clearSaturationCache(). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +const satMod = await import("../../src/lib/quota/saturationSignals.ts"); +const { getSaturation, _clearSaturationCache, __setAnthropicSaturationDepsForTests } = satMod; + +// usage.ts shape: getClaudeUsage maps five_hour.utilization → quotas["session (5h)"].used +// and seven_day.utilization → quotas["weekly (7d)"].used (used = % used, 0..100). +function claudeUsageStub(fiveHourUtil: number, sevenDayUtil: number) { + return { + plan: "Claude Pro", + quotas: { + "session (5h)": { + used: fiveHourUtil, + total: 100, + remaining: Math.max(0, 100 - fiveHourUtil), + remainingPercentage: Math.max(0, 100 - fiveHourUtil), + resetAt: null, + unlimited: false, + }, + "weekly (7d)": { + used: sevenDayUtil, + total: 100, + remaining: Math.max(0, 100 - sevenDayUtil), + remainingPercentage: Math.max(0, 100 - sevenDayUtil), + resetAt: null, + unlimited: false, + }, + }, + extraUsage: null, + bootstrap: null, + }; +} + +const OAUTH_CONN = { + id: "claude-conn-1", + provider: "claude", + authType: "oauth", + accessToken: "fake-oauth-token", +}; + +test.afterEach(() => { + // Restore real deps and clear cache so suites stay isolated. + __setAnthropicSaturationDepsForTests(null); + _clearSaturationCache(); +}); + +// ─── Core: 5h window uses five_hour.utilization ────────────────────────────── + +test("getSaturation(claude, 5h) → five_hour.utilization/100 (80% → 0.8)", async () => { + _clearSaturationCache(); + __setAnthropicSaturationDepsForTests({ + loadConnection: async () => ({ ...OAUTH_CONN }), + fetchUsage: async () => claudeUsageStub(80, 40), + }); + + const val = await getSaturation("claude-conn-1", "claude", { unit: "percent", window: "5h" }); + assert.ok(Math.abs(val - 0.8) < 1e-9, `expected ≈0.8, got ${val}`); +}); + +test("getSaturation(claude, weekly) → seven_day.utilization/100 (40% → 0.4)", async () => { + _clearSaturationCache(); + __setAnthropicSaturationDepsForTests({ + loadConnection: async () => ({ ...OAUTH_CONN }), + fetchUsage: async () => claudeUsageStub(80, 40), + }); + + const val = await getSaturation("claude-conn-1", "claude", { unit: "percent", window: "weekly" }); + assert.ok(Math.abs(val - 0.4) < 1e-9, `expected ≈0.4, got ${val}`); +}); + +test('provider alias "anthropic" resolves the same plan-window utilization', async () => { + _clearSaturationCache(); + __setAnthropicSaturationDepsForTests({ + loadConnection: async () => ({ ...OAUTH_CONN }), + fetchUsage: async () => claudeUsageStub(95, 10), + }); + + const five = await getSaturation("claude-conn-1", "anthropic", { unit: "percent", window: "5h" }); + assert.ok(Math.abs(five - 0.95) < 1e-9, `expected ≈0.95, got ${five}`); +}); + +// ─── Clamp ─────────────────────────────────────────────────────────────────── + +test("utilization > 100 is clamped to 1", async () => { + _clearSaturationCache(); + __setAnthropicSaturationDepsForTests({ + loadConnection: async () => ({ ...OAUTH_CONN }), + fetchUsage: async () => claudeUsageStub(140, 0), + }); + + const val = await getSaturation("claude-conn-1", "claude", { unit: "percent", window: "5h" }); + assert.equal(val, 1); +}); + +// ─── Fallback: API-key Claude (no OAuth token) → header path ────────────────── + +test("API-key Claude (no oauth/usage windows) falls back to rate-limit header path", async () => { + _clearSaturationCache(); + // Seed the header cache the way the response handler would. + satMod.storeRateLimitHeaders("apikey-conn", "anthropic", { + "anthropic-ratelimit-requests-limit": "100", + "anthropic-ratelimit-requests-remaining": "30", + }); + __setAnthropicSaturationDepsForTests({ + // API-key connection → no usable oauth/usage window for plan saturation. + loadConnection: async () => ({ + id: "apikey-conn", + provider: "claude", + authType: "apikey", + apiKey: "sk-ant-xxx", + }), + // Legacy/admin shape: no "session (5h)" / "weekly (7d)" keys. + fetchUsage: async () => ({ message: "Claude connected. Usage details require admin access." }), + }); + + const val = await getSaturation("apikey-conn", "claude", { unit: "percent", window: "5h" }); + // used = 100 - 30 = 70 over limit 100 → 0.7 from the header fallback. + assert.ok(Math.abs(val - 0.7) < 1e-9, `expected header-fallback ≈0.7, got ${val}`); +}); + +// ─── Fail-open ──────────────────────────────────────────────────────────────── + +test("no connection found → fails open (0), no throw", async () => { + _clearSaturationCache(); + __setAnthropicSaturationDepsForTests({ + loadConnection: async () => null, + fetchUsage: async () => { + throw new Error("must not be called when no connection"); + }, + }); + + const val = await getSaturation("missing-conn", "claude", { unit: "percent", window: "5h" }); + assert.equal(val, 0); +}); + +test("oauth/usage fetch throws → fails open (0), no throw", async () => { + _clearSaturationCache(); + __setAnthropicSaturationDepsForTests({ + loadConnection: async () => ({ ...OAUTH_CONN }), + fetchUsage: async () => { + throw new Error("429 rate limited"); + }, + }); + + const val = await getSaturation("claude-conn-1", "claude", { unit: "percent", window: "5h" }); + assert.equal(val, 0); +}); + +// ─── Cache: don't poll per request (Rule #18) ──────────────────────────────── + +test("second call within 30s TTL is served from cache (fetchUsage NOT re-invoked)", async () => { + _clearSaturationCache(); + let calls = 0; + __setAnthropicSaturationDepsForTests({ + loadConnection: async () => ({ ...OAUTH_CONN }), + fetchUsage: async () => { + calls += 1; + return claudeUsageStub(80, 40); + }, + }); + + const a = await getSaturation("claude-conn-1", "claude", { unit: "percent", window: "5h" }); + const b = await getSaturation("claude-conn-1", "claude", { unit: "percent", window: "5h" }); + assert.equal(a, b); + assert.equal(calls, 1, "oauth/usage must be fetched once, not per request"); +}); From f9ccf342c08a4f396f5e142bafdf39e7af8ef9c3 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:37:15 -0300 Subject: [PATCH 065/120] chore(dashboard): rename Qoder display label from "Qoder AI" to "Qoder" (#4733) Integrated into release/v3.8.36 --- src/shared/constants/providers.ts | 2 +- tests/unit/qoder-display-label.test.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 tests/unit/qoder-display-label.test.ts diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 9d45e89bc076..225f1020565f 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -144,7 +144,7 @@ export const OAUTH_PROVIDERS = { qoder: { id: "qoder", alias: "if", - name: "Qoder AI", + name: "Qoder", icon: "water_drop", color: "#6366F1", subscriptionRisk: true, diff --git a/tests/unit/qoder-display-label.test.ts b/tests/unit/qoder-display-label.test.ts new file mode 100644 index 000000000000..4909898bddb5 --- /dev/null +++ b/tests/unit/qoder-display-label.test.ts @@ -0,0 +1,17 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { OAUTH_PROVIDERS, getProviderById } = await import( + "../../src/shared/constants/providers.ts" +); + +test("Qoder provider display label is 'Qoder' (not 'Qoder AI')", () => { + assert.equal((OAUTH_PROVIDERS as Record).qoder.name, "Qoder"); +}); + +test("getProviderById('qoder') resolves the renamed display label", () => { + const provider = getProviderById("qoder"); + assert.ok(provider, "qoder provider should resolve"); + assert.equal(provider.name, "Qoder"); + assert.notEqual(provider.name, "Qoder AI"); +}); From 9b07e0ea59d1f1042204695c81a60c04782020a8 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:38:20 -0300 Subject: [PATCH 066/120] fix(ci): include coverage/lcov.info in coverage-report artifact for SonarQube (#4670) Integrated into release/v3.8.36 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0da08470d4ca..5181ebc246e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -846,6 +846,7 @@ jobs: path: | coverage/coverage-summary.json coverage/coverage-report.md + coverage/lcov.info if-no-files-found: warn sonarqube: From 95e26a07fff716a556371a3fe59318048078eb4a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:38:50 -0300 Subject: [PATCH 067/120] fix(cli): bump better-sqlite3 runtime pin to 12.10.1 for Node 26 (#4685) Integrated into release/v3.8.36 --- bin/cli/runtime/nativeDeps.mjs | 2 +- bin/cli/runtime/sqliteRuntime.mjs | 2 +- .../sqliteRuntime-node26-compat.test.ts | 41 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 tests/unit/runtime/sqliteRuntime-node26-compat.test.ts diff --git a/bin/cli/runtime/nativeDeps.mjs b/bin/cli/runtime/nativeDeps.mjs index f74ab1eea998..852cff76b7da 100644 --- a/bin/cli/runtime/nativeDeps.mjs +++ b/bin/cli/runtime/nativeDeps.mjs @@ -12,7 +12,7 @@ import { spawnSync } from "node:child_process"; import { platform } from "node:os"; import { resolveDataDir } from "../data-dir.mjs"; -const BETTER_SQLITE3_VERSION = "12.9.0"; +const BETTER_SQLITE3_VERSION = "12.10.1"; function runtimeDir() { return join(resolveDataDir(), "runtime"); diff --git a/bin/cli/runtime/sqliteRuntime.mjs b/bin/cli/runtime/sqliteRuntime.mjs index 19dd7907643d..506481a353f3 100644 --- a/bin/cli/runtime/sqliteRuntime.mjs +++ b/bin/cli/runtime/sqliteRuntime.mjs @@ -6,7 +6,7 @@ import { pathToFileURL } from "node:url"; import { validateBinaryMagic, platformBinaryLabel } from "./magicBytes.mjs"; const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime"); -const BETTER_SQLITE3_VERSION = "better-sqlite3@^12.6.2"; +const BETTER_SQLITE3_VERSION = "better-sqlite3@^12.10.1"; let resolvedCached = null; diff --git a/tests/unit/runtime/sqliteRuntime-node26-compat.test.ts b/tests/unit/runtime/sqliteRuntime-node26-compat.test.ts new file mode 100644 index 000000000000..1abd47ebe81b --- /dev/null +++ b/tests/unit/runtime/sqliteRuntime-node26-compat.test.ts @@ -0,0 +1,41 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +// Regression guard for upstream PR decolua/9router#1827 (issue #1812): +// better-sqlite3 < 12.10.1 ships no prebuilt binary for Node 26 (ABI 147), +// causing "Could not locate the bindings file" on startup. The pinned runtime +// versions MUST be >= 12.10.1 so omniroute boots on Node 26.x. +const MIN = [12, 10, 1] as const; + +function parseSemver(v: string): [number, number, number] { + const m = v.match(/(\d+)\.(\d+)\.(\d+)/); + assert.ok(m, `version "${v}" must contain semver`); + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +function gte(a: readonly [number, number, number], b: readonly [number, number, number]) { + for (let i = 0; i < 3; i++) { + if (a[i] > b[i]) return true; + if (a[i] < b[i]) return false; + } + return true; +} + +function readPin(file: string): string { + const src = readFileSync(join(process.cwd(), file), "utf8"); + const m = src.match(/BETTER_SQLITE3_VERSION\s*=\s*["'`]([^"'`]+)["'`]/); + assert.ok(m, `BETTER_SQLITE3_VERSION not found in ${file}`); + return m[1]; +} + +test("bin/cli/runtime/nativeDeps.mjs pins better-sqlite3 >= 12.10.1 (Node 26 prebuilt)", () => { + const pin = readPin("bin/cli/runtime/nativeDeps.mjs"); + assert.ok(gte(parseSemver(pin), MIN), `pin "${pin}" must be >= 12.10.1`); +}); + +test("bin/cli/runtime/sqliteRuntime.mjs pins better-sqlite3 >= 12.10.1 (Node 26 prebuilt)", () => { + const pin = readPin("bin/cli/runtime/sqliteRuntime.mjs"); + assert.ok(gte(parseSemver(pin), MIN), `pin "${pin}" must be >= 12.10.1`); +}); From ec0572d060262bcd18416cb0b61fecac6f4a1934 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:39:19 -0300 Subject: [PATCH 068/120] docs: clarify Kiro is ~50 credits/month per account, not unlimited (#4690) Integrated into release/v3.8.36 --- README.md | 6 +++--- docs/guides/USER_GUIDE.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 150f630fd5bd..852666b8540a 100644 --- a/README.md +++ b/README.md @@ -574,7 +574,7 @@ Dashboard at `http://localhost:20128` · API at `http://localhost:20128/v1`. **2) Connect a FREE provider (no signup)** -Dashboard → **Providers** → connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → done. +Dashboard → **Providers** → connect **Kiro AI** (free Claude, ~50 credits/month per account) or **OpenCode Free** (no auth) → done. **3) Point your coding tool** @@ -743,7 +743,7 @@ podman compose --profile base up -d **$0 forever:** ``` -1. kr/claude-sonnet-4.5 (Kiro — unlimited) +1. kr/claude-sonnet-4.5 (Kiro — ~50 credits/mo per acct) 2. if/kimi-k2-thinking (Qoder — unlimited) 3. pol/gpt-5 (Pollinations — no key) 4. lc/longcat-flash-lite (50M tok/day backup) @@ -800,7 +800,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo | `DATA_DIR` | `~/.omniroute` | Database & config storage | **Will I be charged by OmniRoute?** No — it's free, open-source software on your machine. You only pay paid providers directly. OmniRoute has no billing system. -**Are FREE providers really unlimited?** Yes — Kiro, Qoder, Pollinations, LongCat, Cloudflare. No catch. +**Are FREE providers really unlimited?** Mostly — Qoder, Pollinations, LongCat, and Cloudflare are free with no per-account credit cap. Kiro is free too but capped at ~50 credits/month per account. Stack multiple free providers in a combo and auto-fallback keeps you serving for $0. **Will compression hurt quality?** No — it only compresses the **input**; code, URLs, JSON are always protected. **Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 231 providers. diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 1fb4a419fedc..6c5746312597 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -57,7 +57,7 @@ Complete guide for configuring providers, creating combos, integrating CLI tools | | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost | | **🆓 FREE** | Qoder | $0 | Unlimited | 8 models free | | | Qwen | $0 | Unlimited | 3 models free | -| | Kiro | $0 | Unlimited | Claude free | +| | Kiro | $0 | ~50 credits/mo | Claude free | **💡 Pro Tip:** Start with Gemini CLI (180K free/month) + Qoder (unlimited free) combo = $0 cost! @@ -240,7 +240,7 @@ Models: if/kimi-k2, if/qwen3-coder-plus, if/qwen3-max, if/qwen3-235b, if/deepsee #### Kiro (Claude FREE) ```bash -Dashboard → Connect Kiro → AWS Builder ID or Google/GitHub → Unlimited +Dashboard → Connect Kiro → AWS Builder ID or Google/GitHub → ~50 credits/month Models: kr/claude-sonnet-4.5, kr/claude-haiku-4.5 ``` From db192ce7c669cb5119da2a903ea014058e188783 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:39:43 -0300 Subject: [PATCH 069/120] docs(agentbridge): document Electron NODE_EXTRA_CA_CERTS, real model IDs, identity caveat (#4718) Integrated into release/v3.8.36 --- docs/frameworks/AGENTBRIDGE.md | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/frameworks/AGENTBRIDGE.md b/docs/frameworks/AGENTBRIDGE.md index 29cc6b8ce5a4..9cf32a67e95f 100644 --- a/docs/frameworks/AGENTBRIDGE.md +++ b/docs/frameworks/AGENTBRIDGE.md @@ -200,6 +200,29 @@ certutil -addstore -f Root $env:USERPROFILE\.omniroute\mitm\ca.crt Or use the "Trust Cert" button in the dashboard (runs the appropriate command for your OS, with sudo prompt if needed). +#### Electron-based IDEs ignore the OS trust store (`NODE_EXTRA_CA_CERTS`) + +Some IDEs — notably **Antigravity IDE**, and other Electron / VS Code-derived apps — bundle +their own Node.js runtime that **does not consult the OS trust store** for outbound +`fetch`/HTTPS. Trusting the CA at the OS/NSS level is enough for the IDE's native **backend** +(e.g. a Go language server, which uses the OS CA bundle), but the **Electron frontend** will +still fail TLS — it surfaces as the app being *logged out* or showing a *"connection error"* +even though the MITM log shows the backend's bootstrap calls returning `200`. Two steps are +required, and both matter: + +1. Point the runtime at the CA explicitly: + ```bash + export NODE_EXTRA_CA_CERTS=/path/to/omniroute-agentbridge-ca.crt + ``` +2. **Launch the IDE from that shell.** Starting it from the desktop icon / Dock / Start menu + does **not** inherit shell exports, and `~/.config/environment.d/*.conf` only applies after + a fresh graphical login. Fully quit the IDE first — Electron's singleton lock means a second + launch just focuses the existing process and the new environment is ignored. + +The OS-trust + NSS step above remains necessary (the Chromium network stack used by some auth +flows reads the per-user NSS store, and has its own static pins for `*.googleapis.com` that a +locally-trusted CA overrides). `NODE_EXTRA_CA_CERTS` covers the Node `fetch` path on top of it. + ### 3.3 DNS routing For each agent you want to intercept, its API host(s) must resolve to `127.0.0.1`. AgentBridge manages `/etc/hosts` entries automatically when you toggle DNS for an agent in the Setup Wizard. @@ -221,6 +244,13 @@ Use the Model Mapping Table in each agent card to define source → target mappi Wildcard `*` maps any unrecognized model to the specified target. Persisted in `agent_bridge_mappings` table. +> **Tip — discover the agent's real model IDs.** An IDE may send model names that differ from +> its UI labels and that change between major versions. For example **Antigravity 2** sends +> `gemini-3.1-pro-low`, `gemini-pro-agent`, and `gemini-3.1-flash-lite` over the wire — not the +> `gemini-2.5-pro` shown in older docs. Send one chat with no matching mapping in place: the MITM +> logs the exact incoming `model:` and passes the request through. Map that literal value, then +> the next request is intercepted and routed to your target. + ### 3.5 Risk notice AgentBridge intercepts credentials (OAuth tokens, API keys) that the IDE uses to authenticate with upstream providers. These are **masked before logging** (see §2.7) but are visible to OmniRoute's MITM layer. First activation of each agent shows a dismissible risk notice modal. @@ -358,6 +388,9 @@ When `AGENTBRIDGE_UPSTREAM_CA_CERT` is set, the file is read at startup. If the - **Port 443 requires privilege**: On Linux, AgentBridge needs `setcap 'cap_net_bind_service=+ep'` on the Node binary, or run via `authbind`. The Setup Wizard displays OS-specific instructions. - **IDE restart required**: After DNS redirect, the IDE must be restarted for the new host resolution to take effect. - **Hardcoded OAuth tokens**: Some agents (Kiro, Antigravity) store OAuth refresh tokens locally. These are transparent to AgentBridge — it sees the Bearer token in each request, which is masked before logging. +- **Electron frontends need `NODE_EXTRA_CA_CERTS`**: IDEs whose frontend runs on a bundled Node/Electron runtime ignore the OS/NSS trust store and must be launched from a shell with `NODE_EXTRA_CA_CERTS` set (see §3.2). Symptom when missing: the IDE backend authenticates (MITM shows `200`s) but the UI stays logged out. +- **Multiple installs of the same IDE are independent**: a system install (e.g. `/usr/share/antigravity/antigravity`) and a user-local "Full" install (e.g. `~/AntigravityIDE_Full/antigravity-ide`) are separate processes with their own runtimes — each must be relaunched with the CA injected. Identify which one is running by its binary path before relaunching. +- **Identity is set by the agent's system prompt, not the routed model**: when you remap an agent's model to a different provider, the reply still claims the agent's native identity (e.g. Antigravity answers "I am powered by Gemini") because the IDE injects that into the system prompt. Confirm the real backend in `call_logs` / `proxy_logs` (`provider`, `model`, `target_format`), not by asking the model who it is. --- @@ -382,6 +415,22 @@ If the IDE shows TLS errors after starting AgentBridge: 2. Some apps maintain their own trust store (Firefox, Chrome on Linux). Run "Trust Cert" again and check the NSS/Firefox-specific cert store. 3. Restart the IDE after trusting — in-flight TLS sessions use the old trust state. +### IDE logged out / "connection error" despite a trusted CA + +Symptom: after redirecting DNS and trusting the CA, an Electron-based IDE (e.g. Antigravity) +opens **logged out** or shows an authentication/connection error, yet the MITM log shows the +bootstrap calls (`loadCodeAssist`, `fetchAvailableModels`, …) returning `200`. + +Cause: the IDE's **bundled Node/Electron runtime ignores the OS trust store**. The native +backend (a Go language server) trusts the OS CA and authenticates, but the Electron frontend +does not — so the UI believes it is offline. + +Fix (both steps): export `NODE_EXTRA_CA_CERTS=` **and relaunch the IDE from that +shell**, not from the desktop icon. Fully quit the IDE first — Electron's singleton lock means +a second launch just focuses the existing process and the new environment is ignored. See §3.2. +This mirrors an open upstream report where a standalone agent works through a MITM but the IDE +variant fails under the same setup. + ### DNS not propagated Check that `/etc/hosts` was updated: From 413d1d3827d695524bab8e763a3f0aec57b81ea7 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:40:09 -0300 Subject: [PATCH 070/120] docs(ops): document the release-green family (green-prs, check:release-green, babysit, nightly) (#4679) Integrated into release/v3.8.36 --- docs/ops/RELEASE_CHECKLIST.md | 4 ++ docs/ops/RELEASE_GREEN.md | 85 +++++++++++++++++++++++++++++++++++ docs/ops/meta.json | 1 + 3 files changed, 90 insertions(+) create mode 100644 docs/ops/RELEASE_GREEN.md diff --git a/docs/ops/RELEASE_CHECKLIST.md b/docs/ops/RELEASE_CHECKLIST.md index 29e0688e6bde..ab9c37032b0e 100644 --- a/docs/ops/RELEASE_CHECKLIST.md +++ b/docs/ops/RELEASE_CHECKLIST.md @@ -8,6 +8,10 @@ lastUpdated: 2026-05-13 > **Last updated:** 2026-05-13 — v3.8.0 > Streamlined release flow that leverages Claude Code skills for automation. +> +> **Manter a fila/branch verdes entre releases:** veja [RELEASE_GREEN.md](./RELEASE_GREEN.md) +> (família `/green-prs` + `npm run check:release-green` + `/babysit` + nightly). Rodar +> periodicamente — e principalmente **antes** deste checklist — faz a release PR nascer verde. ## TL;DR diff --git a/docs/ops/RELEASE_GREEN.md b/docs/ops/RELEASE_GREEN.md new file mode 100644 index 000000000000..139696818335 --- /dev/null +++ b/docs/ops/RELEASE_GREEN.md @@ -0,0 +1,85 @@ +# Release-Green: mantendo a fila e a release branch verdes + +## O problema que isto resolve + +O **gate completo** (`.github/workflows/ci.yml` — unit shards, vitest, ratchets, +`package-artifact`, SonarQube, E2E) roda **apenas na release PR** (PR → `main`). PRs para +`release/**` recebem só as **fast-gates** (`quality.yml`: testes TIA-impactados + typecheck + +lint). Consequência: reds se acumulam silenciosamente na release branch e **explodem em camadas +de ~40 min** no momento do release, uma de cada vez. + +A "família release-green" existe para **antecipar** esses reds — validar o equivalente ao gate +completo **localmente / fora do release**, a qualquer momento, para que a release PR já nasça +verde na primeira CI. + +> **Princípio inegociável:** nada disto bloqueia o contribuidor. Não adicionamos um required +> check que falhe o PR dele. O **drift** (ratchets) é do mantenedor rebaselinar no release — +> nunca uma preocupação do contribuidor. Nenhuma peça **fecha** um PR (roubo de crédito) nem +> **enfraquece** um teste para passar. + +## A família (4 peças) — e como cada uma roda à parte + +| Peça | O que é | Quando rodar | Escopo | +| ---- | ------- | ------------ | ------ | +| **`/green-prs`** (Solução A) | Varredura sob demanda do mantenedor sobre a **fila de PRs abertos** | **À parte, periódico** — e principalmente **antes** de um `/generate-release` | Fila inteira de PRs → `release/**` | +| **`/validate-release-green`** (Solução C — `npm run check:release-green`) | Motor de validação: reproduz o gate completo contra uma branch OU um merge-candidato | À parte, a qualquer momento | Uma branch específica ou um PR-merge | +| **`/babysit `** | Conduz a **CI ao vivo** de **um** PR até o verde | À parte, por PR | Um PR | +| **`nightly-release-green.yml`** (Solução D) | Workflow noturno automático; abre issue em HARD red | Automático (cron) | A release branch ativa | + +**Resposta curta à pergunta "é só para release?":** **não.** O `/green-prs` foi desenhado para +rodar **de tempos em tempos, entre releases**. Rodar à parte é o uso normal — o release é apenas +o momento em que rodá-lo dá mais retorno. + +## Solução C — `npm run check:release-green` (o motor) + +Reproduz a validação release-equivalente contra a árvore de trabalho atual e classifica cada red: + +- **HARD** (typecheck, lint errors, unit, vitest, db-rules, public-creds, opcional + `package-artifact`) → **defeito real**; `exit 1`. Conserta-se na branch de origem (TDD, Rule #18). +- **DRIFT** (eslint **warnings**, cognitive-complexity, file-size) → drift de ratchet acumulado no + ciclo, **não é culpa do contribuidor**; é só reportado e **rebaselinado pelo mantenedor no + release**. Drift **nunca** muda o exit code — então nunca bloqueia ninguém. + +```bash +npm run check:release-green # branch atual (working tree) +node scripts/quality/validate-release-green.mjs --json # saída estruturada +node scripts/quality/validate-release-green.mjs --quick # pula unit+vitest (só drift+typecheck+lint) +node scripts/quality/validate-release-green.mjs --with-build # inclui package-artifact (lento) +``` + +Diagnostica e **reporta** apenas (sem auto-fix). A orquestração de fix-to-green vive no +`/green-prs` e no `/review-prs`. + +## Solução A — `/green-prs` (a varredura da fila) + +Procedimento (resumo — ver a skill `green-prs` para o detalhe): + +1. **Inventariar** a fila de PRs abertos contra a release branch ativa. +2. **Triar** cada PR (viável / reject-worthy / needs-author) — reject/needs-author são + **reportados, não fechados** (o dono decide). +3. Para cada viável, em **worktree isolado** (Rule #19), trazer o PR ao tip da release e rodar + `npm run check:release-green`: + - **HARD** → consertar **na branch do contribuidor** via coautoria (mantém o "Merged" do autor), + re-rodar até zerar os HARD. + - **DRIFT** → deixar; é rebaselinado no release. +4. **Reportar** uma tabela PR × (verdict, HARD reds, fixado?, DRIFT, release-green agora?). + +Pode **preparar** a fila sem mergear; só mergeia quando explicitamente pedido — e nunca fecha PR. + +## Cadência recomendada + +- Rode **`/green-prs` periodicamente** (ex.: semanalmente) e **sempre antes de um + `/generate-release`**. +- Deixe o **`nightly-release-green.yml`** (Solução D) como sinal contínuo: quando ele abrir issue + de HARD red, é hora de uma varredura. +- Use **`/validate-release-green`** ad-hoc para checar uma branch ou um merge-candidato pontual. +- Use **`/babysit `** quando um PR específico precisa ser conduzido ao verde na CI ao vivo. + +## Relação com o release + +- `/generate-release` chama a validação na **Fase 0 (pré-flight)**: rebaselina o DRIFT e conserta + o HARD antes de abrir a release PR. +- `/review-prs` usa o gate release-green no passo de decisão de merge (verde-antes-de-merge). + +O objetivo de todas as peças é o mesmo: **a release PR verde na primeira CI**, em vez de surfar +reds em camadas de 40 min no dia do release. diff --git a/docs/ops/meta.json b/docs/ops/meta.json index dfc1af0180fc..5060d2c17fb9 100644 --- a/docs/ops/meta.json +++ b/docs/ops/meta.json @@ -2,6 +2,7 @@ "title": "Operations", "pages": [ "RELEASE_CHECKLIST", + "RELEASE_GREEN", "VM_DEPLOYMENT_GUIDE", "FLY_IO_DEPLOYMENT_GUIDE", "TUNNELS_GUIDE", From 7a6f43209c5d32d406f1c596226b9e2884f7ca84 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:41:16 -0300 Subject: [PATCH 071/120] fix(translator): replay reasoning_content on plain Xiaomi MiMo turns (port from 9router#1321) (#4639) Integrated into release/v3.8.36 --- open-sse/translator/index.ts | 16 +++++-- ...-xiaomi-mimo-reasoning-replay-1321.test.ts | 44 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 tests/unit/translator-xiaomi-mimo-reasoning-replay-1321.test.ts diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index d22ddbb3f864..febb538cb1bb 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -103,14 +103,24 @@ function hasNonEmptyReasoningContent(message: Record): boolean return typeof message.reasoning_content === "string" && message.reasoning_content.length > 0; } -function isDeepSeekReplayTarget(provider: unknown, model: unknown): boolean { +function isReasoningOnlyReplayTarget(provider: unknown, model: unknown): boolean { const normalizedProvider = String(provider ?? "") .trim() .toLowerCase(); const normalizedModel = String(model ?? "") .trim() .toLowerCase(); - return normalizedProvider === "deepseek" || /(^|\/)deepseek/i.test(normalizedModel); + // DeepSeek V4 and Xiaomi MiMo both enforce "pass reasoning_content back on + // subsequent turns" even on PLAIN (non-tool-call) assistant turns. Without + // replaying on those turns the upstream 400s with "Param Incorrect: The + // reasoning_content in the thinking mode must be passed back to the API." + // (deepseek #1682, xiaomi-mimo 9router#1321/#1337). + return ( + normalizedProvider === "deepseek" || + /(^|\/)deepseek/i.test(normalizedModel) || + normalizedProvider === "xiaomi-mimo" || + /(^|\/)mimo/i.test(normalizedModel) + ); } /** @param options.normalizeToolCallId - When true, use 9-char tool call ids (e.g. Mistral); when false, leave ids as-is */ @@ -319,7 +329,7 @@ export function translateRequest( interleavedField: resolvedCapabilities?.interleavedField ?? null, }); if (isReasoner && result.messages && Array.isArray(result.messages)) { - const canReplayReasoningOnly = isDeepSeekReplayTarget(normalizedProvider, normalizedModel); + const canReplayReasoningOnly = isReasoningOnlyReplayTarget(normalizedProvider, normalizedModel); for (const [messageIndex, msg] of result.messages.entries()) { if (msg.role !== "assistant") continue; diff --git a/tests/unit/translator-xiaomi-mimo-reasoning-replay-1321.test.ts b/tests/unit/translator-xiaomi-mimo-reasoning-replay-1321.test.ts new file mode 100644 index 000000000000..dac09a4dbe5d --- /dev/null +++ b/tests/unit/translator-xiaomi-mimo-reasoning-replay-1321.test.ts @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { translateRequest } = await import("../../open-sse/translator/index.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +// Regression for upstream issue 9router#1321 (and the #1337 echo-rules port): +// Xiaomi MiMo thinking models enforce the same contract as DeepSeek V4 — +// "Param Incorrect: The reasoning_content in the thinking mode must be passed +// back to the API." — even on PLAIN (non-tool-call) assistant turns. The +// xiaomi-mimo provider was already in REASONING_REPLAY_PROVIDERS so tool-call +// turns got reasoning_content replayed, but the reasoning-ONLY replay path for +// plain turns was gated by a DeepSeek-only predicate, so a multi-turn text +// conversation whose reasoning_content the client stripped was forwarded +// without the field and rejected with 400. +test("translateRequest replays reasoning_content on plain xiaomi-mimo assistant turns (9router#1321)", () => { + const body = { + model: "mimo-v2.5-pro", + messages: [ + { role: "user", content: "hi" }, + // A plain assistant turn (no tool calls) whose reasoning_content the client dropped. + { role: "assistant", content: "Hello! How can I help?" }, + { role: "user", content: "continue" }, + ], + }; + + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + "mimo-v2.5-pro", + body, + true, + null, + "xiaomi-mimo" + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant, "assistant message present"); + assert.equal( + typeof assistant.reasoning_content === "string" && assistant.reasoning_content.length > 0, + true, + "plain xiaomi-mimo assistant turn must carry a non-empty reasoning_content" + ); +}); From 8251e7b8fbf332d6ba6c36ef0781e69a69b448ca Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:41:53 -0300 Subject: [PATCH 072/120] feat(opencode-go): advertise glm-5.2 and kimi-k2.7-code (align with official Go endpoints) (#4711) Integrated into release/v3.8.36 --- .../providers/registry/opencode/go/index.ts | 6 +++ .../opencode-go-catalog-alignment.test.ts | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 tests/unit/opencode-go-catalog-alignment.test.ts diff --git a/open-sse/config/providers/registry/opencode/go/index.ts b/open-sse/config/providers/registry/opencode/go/index.ts index 224d3deab17b..b1c0b4f12eae 100644 --- a/open-sse/config/providers/registry/opencode/go/index.ts +++ b/open-sse/config/providers/registry/opencode/go/index.ts @@ -13,8 +13,14 @@ export const opencode_goProvider: RegistryEntry = { authPrefix: "Bearer", defaultContextLength: 200000, models: [ + // Port from decolua/9router 8efacc11: align with official Go endpoints — + // glm-5.2 is now advertised and Kimi chat traffic must route through + // `kimi-k2.7-code` (the live API rejects the plain `kimi-k2.7` alias for + // `/chat/completions`, even though the docs config example uses it). + { id: "glm-5.2", name: "GLM-5.2" }, { id: "glm-5.1", name: "GLM-5.1" }, { id: "glm-5", name: "GLM-5" }, + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, { id: "kimi-k2.6", name: "Kimi K2.6" }, { id: "kimi-k2.5", name: "Kimi K2.5" }, { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro" }, diff --git a/tests/unit/opencode-go-catalog-alignment.test.ts b/tests/unit/opencode-go-catalog-alignment.test.ts new file mode 100644 index 000000000000..dd9741a1ddfd --- /dev/null +++ b/tests/unit/opencode-go-catalog-alignment.test.ts @@ -0,0 +1,45 @@ +/** + * OpenCode Go model catalog alignment with the official Go docs. + * + * Port of decolua/9router 8efacc114 (thanks @nguyenha935): the official Go API + * advertises `glm-5.2` and routes Kimi chat traffic through `kimi-k2.7-code` + * (the live API rejects the plain `kimi-k2.7` alias for `/chat/completions` + * even though the public docs example uses it). OmniRoute previously shipped + * the older registry without these IDs. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { opencode_goProvider } = await import( + "../../open-sse/config/providers/registry/opencode/go/index.ts" +); + +function modelIds(): string[] { + return (opencode_goProvider.models ?? []).map((m) => m.id); +} + +test("opencode-go advertises glm-5.2 (official Go endpoint addition)", () => { + assert.ok( + modelIds().includes("glm-5.2"), + `expected glm-5.2 in opencode-go catalog, got: ${modelIds().join(", ")}` + ); +}); + +test("opencode-go advertises kimi-k2.7-code (live API rejects plain kimi-k2.7 for chat)", () => { + assert.ok( + modelIds().includes("kimi-k2.7-code"), + `expected kimi-k2.7-code in opencode-go catalog, got: ${modelIds().join(", ")}` + ); +}); + +test("opencode-go preserves the pre-existing minimax-m3 and qwen routing via targetFormat=claude", () => { + // Routing through the /messages endpoint is OmniRoute's declarative + // equivalent of upstream's MESSAGES_FORMAT_MODELS set; the alignment + // change must not regress this. + const byId = new Map( + (opencode_goProvider.models ?? []).map((m) => [m.id, m as Record]) + ); + assert.equal(byId.get("minimax-m3")?.targetFormat, "claude"); + assert.equal(byId.get("qwen3.7-max")?.targetFormat, "claude"); +}); From 1a1a51c7449e0900e6a49ff65a03092693c69ec2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:46:54 -0300 Subject: [PATCH 073/120] feat(db): track API endpoint dimension on usage_history (#4676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 (migration renumbered 103→105; endpoint plumbed through extracted usage-stats helpers) --- config/quality/file-size-baseline.json | 2 +- open-sse/handlers/chatCore.ts | 3 + open-sse/handlers/chatCore/failureUsage.ts | 2 + .../chatCore/nonStreamingUsageStats.ts | 2 + .../handlers/chatCore/streamingUsageStats.ts | 2 + .../api/internal/codex-responses-ws/route.ts | 1 + .../migrations/105_usage_history_endpoint.sql | 6 + src/lib/db/usageAnalytics.ts | 73 ++++++++++ src/lib/usage/usageHistory.ts | 5 +- tests/unit/usage-endpoint-dimension.test.ts | 125 ++++++++++++++++++ 10 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 src/lib/db/migrations/105_usage_history_endpoint.sql create mode 100644 tests/unit/usage-endpoint-dimension.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index cb897203d31c..ec7bed3a7814 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -199,7 +199,7 @@ "src/lib/db/providers.ts": 1063, "src/lib/db/proxies.ts": 1057, "src/lib/db/settings.ts": 1149, - "src/lib/db/usageAnalytics.ts": 873, + "src/lib/db/usageAnalytics.ts": 925, "src/lib/evals/evalRunner.ts": 961, "src/lib/memory/retrieval.ts": 1171, "src/lib/modelsDevSync.ts": 934, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 6f5cbe4035a4..75db44fa95bd 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -433,6 +433,7 @@ export async function handleChatCore({ statusCode, errorCode, latencyMs: Date.now() - startTime, + endpoint: endpointPath, }) ).catch(() => {}); }; @@ -3422,6 +3423,7 @@ export async function handleChatCore({ effectiveServiceTier, isCombo, comboStrategy, + endpoint: endpointPath, }); // Translate response to client's expected format (usually OpenAI) @@ -3806,6 +3808,7 @@ export async function handleChatCore({ effectiveServiceTier, isCombo, comboStrategy, + endpoint: endpointPath, }); persistAttemptLogs({ diff --git a/open-sse/handlers/chatCore/failureUsage.ts b/open-sse/handlers/chatCore/failureUsage.ts index fd1860eba9f9..52f70fbfee46 100644 --- a/open-sse/handlers/chatCore/failureUsage.ts +++ b/open-sse/handlers/chatCore/failureUsage.ts @@ -19,6 +19,7 @@ export function buildFailureUsageRecord(opts: { statusCode: number; errorCode: string | null | undefined; latencyMs: number; + endpoint?: string | null | undefined; }) { return { provider: opts.provider || "unknown", @@ -35,5 +36,6 @@ export function buildFailureUsageRecord(opts: { apiKeyName: opts.apiKeyInfo?.name || undefined, serviceTier: opts.effectiveServiceTier, comboStrategy: opts.isCombo ? opts.comboStrategy || undefined : undefined, + endpoint: opts.endpoint || undefined, }; } diff --git a/open-sse/handlers/chatCore/nonStreamingUsageStats.ts b/open-sse/handlers/chatCore/nonStreamingUsageStats.ts index d7514e5d44d7..13e4a9ae9125 100644 --- a/open-sse/handlers/chatCore/nonStreamingUsageStats.ts +++ b/open-sse/handlers/chatCore/nonStreamingUsageStats.ts @@ -27,6 +27,7 @@ export type RecordNonStreamingUsageStatsContext = { effectiveServiceTier: EffectiveServiceTier; isCombo: boolean; comboStrategy: string | null | undefined; + endpoint?: string | null | undefined; }; function logUsageTrace( @@ -55,6 +56,7 @@ function persistUsageRow(usage: object, ctx: RecordNonStreamingUsageStatsContext apiKeyName: apiKeyInfo?.name || undefined, serviceTier: effectiveServiceTier, comboStrategy: ctx.isCombo ? ctx.comboStrategy || undefined : undefined, + endpoint: ctx.endpoint || undefined, }).catch((err) => { console.error("Failed to save usage stats:", err.message); }); diff --git a/open-sse/handlers/chatCore/streamingUsageStats.ts b/open-sse/handlers/chatCore/streamingUsageStats.ts index 0561f8099ba4..1a48b8d85722 100644 --- a/open-sse/handlers/chatCore/streamingUsageStats.ts +++ b/open-sse/handlers/chatCore/streamingUsageStats.ts @@ -27,6 +27,7 @@ export type RecordStreamingUsageStatsContext = { effectiveServiceTier: EffectiveServiceTier; isCombo: boolean; comboStrategy: string | null | undefined; + endpoint?: string | null | undefined; }; function persistStreamingUsageRow(usage: object, ctx: RecordStreamingUsageStatsContext): void { @@ -46,6 +47,7 @@ function persistStreamingUsageRow(usage: object, ctx: RecordStreamingUsageStatsC apiKeyName: ctx.apiKeyInfo?.name || undefined, serviceTier: ctx.effectiveServiceTier, comboStrategy: ctx.isCombo ? ctx.comboStrategy || undefined : undefined, + endpoint: ctx.endpoint || undefined, }).catch((err) => { console.error("Failed to save usage stats:", err.message); }); diff --git a/src/app/api/internal/codex-responses-ws/route.ts b/src/app/api/internal/codex-responses-ws/route.ts index 0e6876444409..ddb4760dcfe6 100644 --- a/src/app/api/internal/codex-responses-ws/route.ts +++ b/src/app/api/internal/codex-responses-ws/route.ts @@ -513,6 +513,7 @@ async function persistResponsesWsCallHistory(body: JsonRecord) { latencyMs: durationMs, timeToFirstTokenMs: durationMs, errorCode, + endpoint: "/v1/responses", }); logProxyEvent({ diff --git a/src/lib/db/migrations/105_usage_history_endpoint.sql b/src/lib/db/migrations/105_usage_history_endpoint.sql new file mode 100644 index 000000000000..55a459b1383e --- /dev/null +++ b/src/lib/db/migrations/105_usage_history_endpoint.sql @@ -0,0 +1,6 @@ +-- Migration 105: Add endpoint column to usage_history +-- Tracks the API endpoint path (e.g. /v1/chat/completions, /v1/messages, /v1/responses) +-- so usage analytics can break down activity per endpoint dimension. +-- Backward compatible: existing rows default to NULL; aggregation queries fold NULL into 'unknown'. +ALTER TABLE usage_history ADD COLUMN endpoint TEXT; +CREATE INDEX IF NOT EXISTS idx_uh_endpoint ON usage_history(endpoint); diff --git a/src/lib/db/usageAnalytics.ts b/src/lib/db/usageAnalytics.ts index 2d73c1d92f33..1bdf8596486f 100644 --- a/src/lib/db/usageAnalytics.ts +++ b/src/lib/db/usageAnalytics.ts @@ -821,6 +821,79 @@ export function getPresetCostModelRows( .all(params) as PresetCostModelRow[]; } +// --------------------------------------------------------------------------- +// Endpoint dimension — ported from decolua/9router#152 (thanks @toanalien). +// Reads directly from usage_history (raw rows) so the unified CTE stays +// untouched; matches the pattern used by getAutoRoutingVariantBreakdown. +// --------------------------------------------------------------------------- + +export interface EndpointUsageRow { + endpoint: string; + provider: string; + model: string; + requests: number; + promptTokens: number; + completionTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + reasoningTokens: number; + totalTokens: number; + avgLatencyMs: number; + successfulRequests: number; + lastUsed: string; +} + +export interface EndpointUsageParams { + sinceIso?: string | null; + untilIso?: string | null; +} + +/** + * Per-endpoint × provider × model usage aggregates from `usage_history`. + * NULL endpoints fold into the 'unknown' bucket so legacy rows stay visible. + * + * Inspired by decolua/9router#152 (byEndpoint aggregation), reshaped for the + * OmniRoute SQLite schema + analytics conventions. + */ +export function getEndpointUsageRows(params: EndpointUsageParams = {}): EndpointUsageRow[] { + const db = getDbInstance(); + const conditions: string[] = []; + const bind: Record = {}; + if (params.sinceIso) { + conditions.push("timestamp >= @since"); + bind.since = params.sinceIso; + } + if (params.untilIso) { + conditions.push("timestamp <= @until"); + bind.until = params.untilIso; + } + const whereSql = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + return db + .prepare( + ` + SELECT + COALESCE(NULLIF(endpoint, ''), 'unknown') as endpoint, + LOWER(COALESCE(provider, 'unknown')) as provider, + LOWER(COALESCE(model, 'unknown')) as model, + COUNT(*) as requests, + COALESCE(SUM(tokens_input), 0) as promptTokens, + COALESCE(SUM(tokens_output), 0) as completionTokens, + COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens, + COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens, + COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens, + COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens, + COALESCE(AVG(latency_ms), 0) as avgLatencyMs, + COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests, + COALESCE(MAX(timestamp), '') as lastUsed + FROM usage_history + ${whereSql} + GROUP BY endpoint, LOWER(COALESCE(provider, 'unknown')), LOWER(COALESCE(model, 'unknown')) + ORDER BY requests DESC + ` + ) + .all(bind) as EndpointUsageRow[]; +} + // --------------------------------------------------------------------------- // Export-JSON backup — /api/settings/export-json // --------------------------------------------------------------------------- diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index f22d053e46c0..0e5ed1fe8282 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -641,8 +641,8 @@ export async function saveRequestUsage(entry: any) { ` INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning, - service_tier, status, success, latency_ms, ttft_ms, error_code, combo_strategy, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + service_tier, status, success, latency_ms, ttft_ms, error_code, combo_strategy, endpoint, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` ).run( entry.provider || null, @@ -666,6 +666,7 @@ export async function saveRequestUsage(entry: any) { : 0, entry.errorCode || null, entry.comboStrategy || entry.combo_strategy || null, + entry.endpoint || null, timestamp ); diff --git a/tests/unit/usage-endpoint-dimension.test.ts b/tests/unit/usage-endpoint-dimension.test.ts new file mode 100644 index 000000000000..9712d9240726 --- /dev/null +++ b/tests/unit/usage-endpoint-dimension.test.ts @@ -0,0 +1,125 @@ +/** + * Ported feature regression — decolua/9router#152 (thanks @toanalien). + * + * Covers the new `endpoint` column on usage_history + getEndpointUsageRows() + * aggregation. Asserts: persistence round-trip, NULL → 'unknown' folding, + * per-endpoint grouping, sinceIso filter. + */ +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-usage-endpoint-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); +const usageAnalytics = await import("../../src/lib/db/usageAnalytics.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + usageHistory.clearPendingRequests(); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("saveRequestUsage persists endpoint and getEndpointUsageRows groups by endpoint", async () => { + await usageHistory.saveRequestUsage({ + provider: "openai", + model: "gpt-4o-mini", + tokens: { input: 10, output: 5 }, + success: true, + latencyMs: 100, + timestamp: new Date().toISOString(), + endpoint: "/v1/chat/completions", + }); + await usageHistory.saveRequestUsage({ + provider: "openai", + model: "gpt-4o-mini", + tokens: { input: 20, output: 8 }, + success: true, + latencyMs: 200, + timestamp: new Date().toISOString(), + endpoint: "/v1/chat/completions", + }); + await usageHistory.saveRequestUsage({ + provider: "anthropic", + model: "claude-sonnet-4", + tokens: { input: 30, output: 12 }, + success: true, + latencyMs: 300, + timestamp: new Date().toISOString(), + endpoint: "/v1/messages", + }); + + const rows = usageAnalytics.getEndpointUsageRows(); + const byKey = new Map(rows.map((r) => [`${r.endpoint}|${r.provider}|${r.model}`, r])); + + const chat = byKey.get("/v1/chat/completions|openai|gpt-4o-mini"); + assert.ok(chat, "expected /v1/chat/completions row"); + assert.equal(chat.requests, 2); + assert.equal(chat.promptTokens, 30); + assert.equal(chat.completionTokens, 13); + + const messages = byKey.get("/v1/messages|anthropic|claude-sonnet-4"); + assert.ok(messages, "expected /v1/messages row"); + assert.equal(messages.requests, 1); + assert.equal(messages.promptTokens, 30); +}); + +test("getEndpointUsageRows folds NULL endpoint into 'unknown' bucket (backward compat)", async () => { + // Legacy entry: no endpoint field set → stored as NULL. + await usageHistory.saveRequestUsage({ + provider: "openai", + model: "gpt-4o-mini", + tokens: { input: 5, output: 5 }, + success: true, + latencyMs: 50, + timestamp: new Date().toISOString(), + }); + + const rows = usageAnalytics.getEndpointUsageRows(); + const unknown = rows.find((r) => r.endpoint === "unknown"); + assert.ok(unknown, "NULL endpoint should fold into 'unknown'"); + assert.equal(unknown.requests, 1); +}); + +test("getEndpointUsageRows honors sinceIso filter", async () => { + const old = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const recent = new Date().toISOString(); + await usageHistory.saveRequestUsage({ + provider: "openai", + model: "gpt-4o-mini", + tokens: { input: 5, output: 5 }, + success: true, + latencyMs: 50, + timestamp: old, + endpoint: "/v1/chat/completions", + }); + await usageHistory.saveRequestUsage({ + provider: "openai", + model: "gpt-4o-mini", + tokens: { input: 5, output: 5 }, + success: true, + latencyMs: 50, + timestamp: recent, + endpoint: "/v1/chat/completions", + }); + + const sinceIso = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const rows = usageAnalytics.getEndpointUsageRows({ sinceIso }); + const chat = rows.find((r) => r.endpoint === "/v1/chat/completions"); + assert.ok(chat); + assert.equal(chat.requests, 1, "only the recent row should be counted"); +}); From 078785cbf322764aeb69c50dc44a68680b167787 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:47:40 -0300 Subject: [PATCH 074/120] fix(cli): SIGKILL systray child PID before IPC close to avoid macOS NSStatusItem orphan (#4732) Integrated into release/v3.8.36 --- bin/cli/tray/traySystray.mjs | 24 ++++++ tests/unit/tray-kill-orphan-pid.test.ts | 97 +++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 tests/unit/tray-kill-orphan-pid.test.ts diff --git a/bin/cli/tray/traySystray.mjs b/bin/cli/tray/traySystray.mjs index bf0b7caee060..4c5bcb3c5e8a 100644 --- a/bin/cli/tray/traySystray.mjs +++ b/bin/cli/tray/traySystray.mjs @@ -100,8 +100,32 @@ export function initSystrayUnix({ port, onQuit, onOpenDashboard, onShowLogs }) { return tray; } +/** + * Resolve the Go systray2 child subprocess PID from a tray instance. + * systray2 exposes the spawned binary either as the `_process` field or via a + * `process()` accessor depending on version. Returns the numeric PID or null. + */ +export function getSystrayChildPid(tray) { + if (!tray) return null; + try { + const proc = tray._process || (typeof tray.process === "function" ? tray.process() : null); + if (proc && typeof proc.pid === "number") return proc.pid; + } catch {} + return null; +} + export function killSystrayUnix(tray) { try { + // systray2.kill(false) closes the IPC channel but leaves the Go tray binary + // subprocess running, which keeps an orphan NSStatusItem on macOS and blocks + // a freshly spawned tray (e.g. on respawn / hide-to-tray) from registering. + // SIGKILL the child PID directly first, then close IPC. + const pid = getSystrayChildPid(tray); + if (pid) { + try { + process.kill(pid, "SIGKILL"); + } catch {} + } tray.kill(false); } catch {} } diff --git a/tests/unit/tray-kill-orphan-pid.test.ts b/tests/unit/tray-kill-orphan-pid.test.ts new file mode 100644 index 000000000000..5887effc411b --- /dev/null +++ b/tests/unit/tray-kill-orphan-pid.test.ts @@ -0,0 +1,97 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + getSystrayChildPid, + killSystrayUnix, +} from "../../bin/cli/tray/traySystray.mjs"; + +// Regression guard for the macOS orphan-NSStatusItem bug: systray2.kill(false) +// only closes the IPC channel and leaves the Go tray binary subprocess running. +// killSystrayUnix() must SIGKILL the child PID FIRST so a freshly spawned tray +// (respawn / hide-to-tray) can register a new NSStatusItem. + +test("getSystrayChildPid reads pid from the _process field", () => { + const tray = { _process: { pid: 4242 } }; + assert.equal(getSystrayChildPid(tray), 4242); +}); + +test("getSystrayChildPid falls back to the process() accessor", () => { + const tray = { process: () => ({ pid: 777 }) }; + assert.equal(getSystrayChildPid(tray), 777); +}); + +test("getSystrayChildPid returns null when no child process is exposed", () => { + assert.equal(getSystrayChildPid({}), null); + assert.equal(getSystrayChildPid(null), null); + assert.equal(getSystrayChildPid({ _process: {} }), null); +}); + +test("killSystrayUnix SIGKILLs the child PID BEFORE closing IPC", () => { + const calls: string[] = []; + const origKill = process.kill; + let killedPid: number | undefined; + let killedSignal: string | number | undefined; + + // @ts-expect-error - patching process.kill for the test + process.kill = (pid: number, signal?: string | number) => { + killedPid = pid; + killedSignal = signal; + calls.push("process.kill"); + return true; + }; + + try { + const tray = { + _process: { pid: 9001 }, + kill(closeOnly: boolean) { + calls.push(`tray.kill(${closeOnly})`); + }, + }; + + killSystrayUnix(tray); + + // The child PID is SIGKILLed first, then the IPC channel is closed. + assert.deepEqual(calls, ["process.kill", "tray.kill(false)"]); + assert.equal(killedPid, 9001); + assert.equal(killedSignal, "SIGKILL"); + } finally { + process.kill = origKill; + } +}); + +test("killSystrayUnix still closes IPC when no child PID is available", () => { + const calls: string[] = []; + const origKill = process.kill; + + // @ts-expect-error - patching process.kill for the test + process.kill = () => { + calls.push("process.kill"); + return true; + }; + + try { + const tray = { + kill(closeOnly: boolean) { + calls.push(`tray.kill(${closeOnly})`); + }, + }; + + killSystrayUnix(tray); + + // No PID known -> never calls process.kill, but still closes IPC. + assert.deepEqual(calls, ["tray.kill(false)"]); + } finally { + process.kill = origKill; + } +}); + +test("killSystrayUnix swallows errors from a broken tray instance", () => { + const tray = { + kill() { + throw new Error("boom"); + }, + }; + // Must not throw. + assert.doesNotThrow(() => killSystrayUnix(tray)); +}); From 8080800d8f0cd1372d9f5cac2406e485b807bb89 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:57:43 -0300 Subject: [PATCH 075/120] feat(proxy-pool): Cloudflare Workers proxy deployer + pool integration (#4640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.36 (relay type added to RELAY_TYPES set; dropdown UX preserved + Cloudflare item added; proxies.ts file-size rebaselined 1057→1060) --- .env.example | 14 ++ config/quality/file-size-baseline.json | 2 +- docs/reference/ENVIRONMENT.md | 3 + open-sse/utils/proxyDispatcher.ts | 10 +- .../components/proxy/CloudflareRelayModal.tsx | 165 ++++++++++++++++ .../components/proxy/ProxyPoolTab.tsx | 38 +++- .../settings/proxy/cloudflare-deploy/route.ts | 178 +++++++++++++++++ src/i18n/messages/en.json | 15 ++ src/lib/db/proxies.ts | 9 +- src/lib/proxyRelay/cloudflareWorkerScript.ts | 107 ++++++++++ src/shared/validation/freeProxySchemas.ts | 30 +++ ...y-pool-cloudflare-workers-deployer.test.ts | 183 ++++++++++++++++++ 12 files changed, 742 insertions(+), 12 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx create mode 100644 src/app/api/settings/proxy/cloudflare-deploy/route.ts create mode 100644 src/lib/proxyRelay/cloudflareWorkerScript.ts create mode 100644 tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts diff --git a/.env.example b/.env.example index 393f267015e2..a487fe020084 100644 --- a/.env.example +++ b/.env.example @@ -1244,6 +1244,20 @@ APP_LOG_TO_FILE=true # Used by: src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx # NEXT_PUBLIC_DENO_RELAY_ENABLED=true +# ── Cloudflare Workers proxy relay (#4640 / 9router#1360) ── +# Override the Cloudflare REST API base used by the proxy-pool relay deployer. +# Default: https://api.cloudflare.com/client/v4 (omit unless mocking). +# Used by: src/app/api/settings/proxy/cloudflare-deploy/route.ts +# CLOUDFLARE_API_BASE=https://api.cloudflare.com/client/v4 + +# Default worker project name suggested in the "Deploy Relay" modal. +# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx +# NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT=omniroute-relay + +# Set to "false" to hide the Cloudflare Workers relay option from the Proxy Pool tab. +# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx +# NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED=true + # ── Cloudflare Tunnel (cloudflared) ── # Custom path to cloudflared binary for tunnel management. # Used by: src/lib/cloudflaredTunnel.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index ec7bed3a7814..ba522cd7de2c 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -197,7 +197,7 @@ "src/lib/db/migrationRunner.ts": 1125, "src/lib/db/models.ts": 1259, "src/lib/db/providers.ts": 1063, - "src/lib/db/proxies.ts": 1057, + "src/lib/db/proxies.ts": 1060, "src/lib/db/settings.ts": 1149, "src/lib/db/usageAnalytics.ts": 925, "src/lib/evals/evalRunner.ts": 961, diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 734d33711ded..e038437da0d2 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -750,6 +750,9 @@ Automatic model pricing data synchronization from external sources. | `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. | diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index e7871722530f..18c2f409b618 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -13,7 +13,7 @@ const SUPPORTED_PROTOCOLS = new Set(["http:", "https:", "socks5:"]); // the caller wraps the upstream URL with buildRelayHeaders() and fetches the // relay endpoint directly. Keep this set as the single source of truth so // every dispatch decision stays in sync when a new relay backend lands. -export const RELAY_TYPES: ReadonlySet = new Set(["vercel", "deno"]); +export const RELAY_TYPES: ReadonlySet = new Set(["vercel", "deno", "cloudflare"]); export function isRelayType(type: string | undefined | null): boolean { return typeof type === "string" && RELAY_TYPES.has(type); @@ -415,9 +415,11 @@ export function proxyConfigToUrl( if (!config.host) return null; const type = String(config.type || "http").toLowerCase(); - // Edge-relay entries (vercel / deno) carry the relay URL in `host` — no - // dispatcher needed; callers should use buildRelayHeaders() and fetch - // the relay endpoint directly. + // Edge-relay entries (vercel / deno / cloudflare) carry the relay URL in + // `host` — no dispatcher needed; callers should use buildRelayHeaders() and + // fetch the relay endpoint directly. All relay types share the exact same + // x-relay-target / x-relay-path / x-relay-auth header spec (only the + // deployment target differs). if (RELAY_TYPES.has(type)) { return config.host ? `https://${config.host}` : null; } diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx new file mode 100644 index 000000000000..e94ef2bdb8b3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx @@ -0,0 +1,165 @@ +"use client"; +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/shared/components"; + +interface CloudflareRelayModalProps { + isOpen: boolean; + onClose: () => void; + onDeployed: (poolProxyId: string, relayUrl: string) => void; +} + +// Mirrors VercelRelayModal — shares the same x-relay-target/x-relay-auth +// header scheme on the wire, only the deployment surface differs. +export default function CloudflareRelayModal({ + isOpen, + onClose, + onDeployed, +}: CloudflareRelayModalProps) { + const t = useTranslations("settings"); + const [accountId, setAccountId] = useState(""); + const [apiToken, setApiToken] = useState(""); + const [projectName, setProjectName] = useState( + process.env.NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT || "omniroute-relay" + ); + const [deploying, setDeploying] = useState(false); + const [error, setError] = useState(null); + + const handleDeploy = async () => { + if (!accountId.trim() || !apiToken.trim()) { + setError(t("cloudflareRelayCredsRequired")); + return; + } + setDeploying(true); + setError(null); + try { + const res = await fetch("/api/settings/proxy/cloudflare-deploy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accountId: accountId.trim(), + apiToken: apiToken.trim(), + projectName: projectName.trim(), + }), + }); + const data = await res.json(); + if (!res.ok || !data.success) { + setError(data.error?.message || t("cloudflareRelayDeployFailed")); + } else { + setApiToken(""); + onDeployed(data.poolProxyId as string, data.relayUrl as string); + onClose(); + } + } catch (err) { + setError(err instanceof Error ? err.message : t("unknownError")); + } finally { + setDeploying(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+

+ + {t("cloudflareRelayModalTitle")} +

+ +
+ +
+

{t("cloudflareRelayWarning")}

+

{t("cloudflareRelayTokenHowto")}

+
+ +
+
+ + setAccountId(e.target.value)} + className="w-full text-sm bg-surface-alt border border-border rounded px-3 py-2 focus:outline-none focus:border-primary" + placeholder="your-cloudflare-account-id" + autoComplete="off" + /> +

+ {t("cloudflareRelayAccountIdHint")} +

+
+
+ + setApiToken(e.target.value)} + className="w-full text-sm bg-surface-alt border border-border rounded px-3 py-2 focus:outline-none focus:border-primary" + placeholder="cloudflare-api-token" + autoComplete="off" + /> +

+ {t("cloudflareRelayApiTokenHint")} +

+
+
+ + setProjectName(e.target.value)} + className="w-full text-sm bg-surface-alt border border-border rounded px-3 py-2 focus:outline-none focus:border-primary" + placeholder="omniroute-relay" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +

{t("cloudflareRelayFreeTierNote")}

+ +
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx index 36ea5bc46ff4..e31b8b1dd3b5 100644 --- a/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx @@ -5,17 +5,20 @@ import { useTranslations } from "next-intl"; import ProxyRegistryManager from "../ProxyRegistryManager"; import VercelRelayModal from "./VercelRelayModal"; import DenoRelayModal from "./DenoRelayModal"; +import CloudflareRelayModal from "./CloudflareRelayModal"; export default function ProxyPoolTab() { const t = useTranslations("settings"); const [vercelModalOpen, setVercelModalOpen] = useState(false); const [denoModalOpen, setDenoModalOpen] = useState(false); + const [cloudflareModalOpen, setCloudflareModalOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const menuRef = useRef(null); const showVercelRelay = process.env.NEXT_PUBLIC_VERCEL_RELAY_ENABLED !== "false"; const showDenoRelay = process.env.NEXT_PUBLIC_DENO_RELAY_ENABLED !== "false"; - const showAnyRelay = showVercelRelay || showDenoRelay; + const showCloudflareRelay = process.env.NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED !== "false"; + const showAnyRelay = showVercelRelay || showDenoRelay || showCloudflareRelay; // Close the dropdown on outside click — mirrors the upstream PR-1437 // grouped-button UX so adding more relay backends does not blow up the @@ -31,10 +34,14 @@ export default function ProxyPoolTab() { return () => document.removeEventListener("mousedown", onMouseDown); }, [menuOpen]); - const handleDeployed = (_poolProxyId: string, relayUrl: string) => { + const handleVercelDeployed = (_poolProxyId: string, relayUrl: string) => { alert(`${t("vercelRelaySuccess")}: ${relayUrl}`); }; + const handleCloudflareDeployed = (_poolProxyId: string, relayUrl: string) => { + alert(`${t("cloudflareRelaySuccess")}: ${relayUrl}`); + }; + return (
{showAnyRelay && ( @@ -86,6 +93,24 @@ export default function ProxyPoolTab() { {t("denoRelayButton")} )} + {showCloudflareRelay && ( + + )}
)}
@@ -95,12 +120,17 @@ export default function ProxyPoolTab() { setVercelModalOpen(false)} - onDeployed={handleDeployed} + onDeployed={handleVercelDeployed} /> setDenoModalOpen(false)} - onDeployed={handleDeployed} + onDeployed={handleVercelDeployed} + /> + setCloudflareModalOpen(false)} + onDeployed={handleCloudflareDeployed} />
); diff --git a/src/app/api/settings/proxy/cloudflare-deploy/route.ts b/src/app/api/settings/proxy/cloudflare-deploy/route.ts new file mode 100644 index 000000000000..e1d35a3ac659 --- /dev/null +++ b/src/app/api/settings/proxy/cloudflare-deploy/route.ts @@ -0,0 +1,178 @@ +import { randomBytes } from "crypto"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { cloudflareDeploySchema } from "@/shared/validation/freeProxySchemas"; +import { createProxy } from "@/lib/localDb"; +import { encrypt } from "@/lib/db/encryption"; +import { buildCloudflareWorkerScript } from "@/lib/proxyRelay/cloudflareWorkerScript"; + +// Port of upstream decolua/9router PR #1360 — Cloudflare Workers proxy relay. +// Architecture mirrors src/app/api/settings/proxy/vercel-deploy/route.ts so the +// shared proxyFetch relay short-circuit, x-relay-auth scheme, and inline SSRF +// guard work unchanged. Only the deployment surface differs (Cloudflare Workers +// API instead of Vercel /v13/deployments). + +const CLOUDFLARE_API_BASE = process.env.CLOUDFLARE_API_BASE || "https://api.cloudflare.com/client/v4"; + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown = {}; + try { + rawBody = await request.json(); + } catch { + return createErrorResponse({ + status: 400, + message: "Invalid JSON body", + type: "invalid_request", + }); + } + + const validation = validateBody(cloudflareDeploySchema, rawBody); + if (isValidationFailure(validation)) { + return createErrorResponse({ + status: 400, + message: validation.error.message, + type: "invalid_request", + }); + } + + const { accountId, apiToken, projectName } = validation.data; + + // Generate random auth secret for the relay — stored in proxy notes, never + // returned to client. Same scheme as the Vercel relay so the deployed worker + // is not an open SSRF proxy reachable from any third party with the workers.dev URL. + const relayAuth = randomBytes(24).toString("hex"); + const workerScript = buildCloudflareWorkerScript(relayAuth); + + try { + // 1. PUT the Worker script — Cloudflare requires multipart/form-data with + // main_module + a metadata blob describing the upload. + const workerScriptUrl = `${CLOUDFLARE_API_BASE}/accounts/${accountId}/workers/scripts/${projectName}`; + const formData = new FormData(); + formData.append( + "index.js", + new Blob([workerScript], { type: "application/javascript+module" }), + "index.js" + ); + formData.append( + "metadata", + new Blob( + [ + JSON.stringify({ + main_module: "index.js", + compatibility_date: "2026-03-20", + observability: { enabled: true }, + }), + ], + { type: "application/json" } + ), + "metadata.json" + ); + + const uploadRes = await fetch(workerScriptUrl, { + method: "PUT", + headers: { Authorization: `Bearer ${apiToken}` }, + body: formData, + }); + + if (!uploadRes.ok) { + // Surface only the canonical Cloudflare error message; never forward raw + // response text (may carry internal IDs / token hints). + let upstreamMessage = "Cloudflare API rejected the Worker upload"; + try { + const parsed = (await uploadRes.json().catch(() => null)) as { + errors?: Array<{ message?: string }>; + } | null; + const candidate = parsed?.errors?.[0]?.message; + if (typeof candidate === "string" && candidate.trim()) { + upstreamMessage = candidate.trim().slice(0, 200); + } + } catch { + /* fall through to generic message */ + } + return createErrorResponse({ + status: uploadRes.status, + message: `Cloudflare Worker upload failed: ${upstreamMessage}`, + type: "upstream_error", + }); + } + + // 2. Enable the workers.dev subdomain for this script so it is reachable. + // A failure here is non-fatal (some accounts already enable subdomains + // by default); the next call surfaces the correct error if anything is + // actually missing. + await fetch(`${workerScriptUrl}/subdomain`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled: true }), + }).catch(() => {}); + + // 3. Look up the account's workers.dev subdomain to build the final URL. + const subdomainRes = await fetch( + `${CLOUDFLARE_API_BASE}/accounts/${accountId}/workers/subdomain`, + { + method: "GET", + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + }, + } + ); + + let deployUrl = ""; + if (subdomainRes.ok) { + const subdomainData = (await subdomainRes.json().catch(() => null)) as { + result?: { subdomain?: string }; + } | null; + const sub = subdomainData?.result?.subdomain; + if (typeof sub === "string" && sub) { + deployUrl = `https://${projectName}.${sub}.workers.dev`; + } + } + + if (!deployUrl) { + return createErrorResponse({ + status: 400, + message: + "Worker deployed but failed to retrieve workers.dev subdomain. Set up a workers.dev subdomain in the Cloudflare dashboard first.", + type: "upstream_error", + }); + } + + // Store as proxy pool entry — apiToken is NOT stored. relayAuth is + // encrypted at rest when STORAGE_ENCRYPTION_KEY is configured (encrypt() is + // a no-op in passthrough mode); the redactor strips both shapes from API responses. + const encryptedRelayAuth = encrypt(relayAuth); + const notesPayload = + encryptedRelayAuth && encryptedRelayAuth !== relayAuth + ? { relayAuthEnc: encryptedRelayAuth } + : { relayAuth }; + + // deployUrl is "https://..workers.dev" — strip the protocol so + // the `host` column matches the Vercel-relay shape (proxyFetch prepends + // "https://" when routing). + const hostOnly = deployUrl.replace(/^https?:\/\//, ""); + const poolProxy = await createProxy({ + name: `Cloudflare Relay (${projectName})`, + type: "cloudflare", + host: hostOnly, + port: 443, + notes: JSON.stringify(notesPayload), + source: "cloudflare-relay", + }); + + return Response.json({ + success: true, + relayUrl: deployUrl, + poolProxyId: poolProxy?.id, + }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Cloudflare deploy failed"); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 5712cec4be64..200708bfccd3 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -6037,6 +6037,21 @@ "denoRelayFreeTierNote": "Deno Deploy v2 runs on a global edge network. Free tier: 1M requests & 100GiB outbound traffic per month, no per-request CPU limits, up to 20 active apps & 50 custom domains.", "denoRelayDeploying": "Deploying...", "denoRelayDeploy": "Deploy", + "cloudflareRelaySuccess": "Cloudflare Relay deployed successfully", + "cloudflareRelayButton": "Deploy Cloudflare Relay", + "cloudflareRelayModalTitle": "Deploy Cloudflare Worker Relay", + "cloudflareRelayWarning": "Deploys a Cloudflare Worker that proxies LLM requests through Cloudflare's edge network — masking the host IP behind dynamic Cloudflare addresses. The Worker enforces a one-time auth secret so the public workers.dev URL cannot be reused as an open relay.", + "cloudflareRelayTokenHowto": "Create the API token under My Profile -> API Tokens -> Create Token -> Custom Token -> Account / Workers Scripts / Edit.", + "cloudflareRelayAccountIdLabel": "Cloudflare Account ID", + "cloudflareRelayAccountIdHint": "Found on the right side of the Cloudflare dashboard overview page.", + "cloudflareRelayApiTokenLabel": "Cloudflare API Token", + "cloudflareRelayApiTokenHint": "Requires 'Workers Scripts: Edit' permission. The token is used only at deploy time and is never stored.", + "cloudflareRelayProjectNameLabel": "Worker Name", + "cloudflareRelayFreeTierNote": "Free tier: 100,000 requests/day per Cloudflare account.", + "cloudflareRelayDeploying": "Deploying...", + "cloudflareRelayDeploy": "Deploy", + "cloudflareRelayCredsRequired": "Account ID and API Token are required", + "cloudflareRelayDeployFailed": "Deploy failed", "modelLockout": "Model Lockout", "modelLockoutPageDescription": "Configure which HTTP error codes trigger per-model lockout and control the cooldown behavior.", "modelLockoutEnabled": "Enable Model Lockout", diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index 231a26d41173..43bd7d914e0a 100755 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -1,5 +1,8 @@ -// Convention: when type is a relay (vercel | deno), the `notes` column stores JSON { relayAuth: "" } -// used by proxyFetch.ts to route requests through the Vercel edge relay instead of an undici ProxyAgent. +// Convention: when type is a relay (vercel | deno | cloudflare), the `notes` column stores JSON +// { relayAuth: "" } used by proxyFetch.ts to route requests through the relay edge function +// (Vercel Edge, Deno Deploy, or Cloudflare Workers) instead of an undici ProxyAgent. All relay +// types share the exact same x-relay-target / x-relay-path / x-relay-auth header spec; only the +// deployment surface differs. import { randomUUID } from "crypto"; import { getDbInstance } from "./core"; import { backupDbFile } from "./backup"; @@ -122,7 +125,7 @@ function mapAssignmentRow(row: unknown): ProxyAssignmentRecord { // Edge-relay proxy types. Mirrors RELAY_TYPES in open-sse/utils/proxyDispatcher. // Duplicated here (not imported) to keep src/lib/db/ free of open-sse runtime // imports; if a third relay backend lands, update BOTH sets. -const RELAY_PROXY_TYPES = new Set(["vercel", "deno"]); +const RELAY_PROXY_TYPES = new Set(["vercel", "deno", "cloudflare"]); function isRelayProxyType(type: unknown): boolean { return typeof type === "string" && RELAY_PROXY_TYPES.has(type); diff --git a/src/lib/proxyRelay/cloudflareWorkerScript.ts b/src/lib/proxyRelay/cloudflareWorkerScript.ts new file mode 100644 index 000000000000..d8a639b376a0 --- /dev/null +++ b/src/lib/proxyRelay/cloudflareWorkerScript.ts @@ -0,0 +1,107 @@ +/** + * Cloudflare Worker source emitter for the OmniRoute proxy relay. + * + * Port of upstream decolua/9router PR #1360. The Worker plays the same role + * the Vercel-relay edge function does (`src/app/api/settings/proxy/vercel-deploy/route.ts`): + * - Accepts inbound requests carrying x-relay-target / x-relay-path / + * x-relay-auth headers. + * - Authorises with the embedded relayAuth secret (so a leaked workers.dev URL + * is not an open SSRF proxy). + * - Inlines an SSRF guard rejecting RFC1918 / loopback / link-local / IPv6 ULA + * targets — the Edge runtime cannot import Node helpers, the guard lives + * here as a string. + * - Strips Host + relay control headers before forwarding upstream. + * + * The string template is fed to Cloudflare's PUT /accounts/{id}/workers/scripts/{name} + * API with main_module=index.js (ESM Workers Modules format). + * + * The OmniRoute variant intentionally diverges from the upstream PR: + * - The upstream worker had NO auth check, leaving the deployed workers.dev URL + * as an open SSRF proxy. We mirror Vercel's x-relay-auth scheme instead so the + * same buildVercelRelayHeaders helper (open-sse/utils/proxyDispatcher.ts) and + * the same proxyFetch relay short-circuit work unchanged. + * - SSRF guard is inlined so a leaked relay URL cannot scan internal IPs. + */ +export function buildCloudflareWorkerScript(relayAuth: string): string { + // relayAuth is generated server-side via randomBytes(24).toString("hex") — no + // user-controlled input ever reaches this template, so direct interpolation + // into the worker source string is safe. + return `// OmniRoute Cloudflare Worker proxy relay — generated at deploy time. +function isPrivateHostname(h) { + if (!h) return true; + const host = h.trim().toLowerCase().replace(/^\\[|\\]$/g, ""); + if ( + host === "localhost" || + host === "0.0.0.0" || + host === "127.0.0.1" || + host === "::1" || + host.endsWith(".localhost") || + host.endsWith(".local") || + host.endsWith(".internal") || + host.startsWith("::ffff:") + ) return true; + const v4 = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/); + if (v4) { + const a = +v4[1], b = +v4[2]; + if (a === 0 || a === 10 || a === 127) return true; + if (a === 169 && b === 254) return true; // link-local IPv4 + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + return false; + } + if (host.includes(":")) { + // IPv6 loopback/ULA/link-local (fe80::/10) + return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:"); + } + return false; +} + +export default { + async fetch(request, env, ctx) { + const auth = request.headers.get("x-relay-auth"); + if (auth !== "${relayAuth}") { + return new Response("Unauthorized", { status: 401 }); + } + const target = request.headers.get("x-relay-target"); + if (!target) { + return new Response("missing x-relay-target", { status: 400 }); + } + let targetUrl; + try { targetUrl = new URL(target); } catch { return new Response("invalid x-relay-target", { status: 400 }); } + if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") { + return new Response("forbidden x-relay-target protocol", { status: 403 }); + } + if (targetUrl.username || targetUrl.password) { + return new Response("forbidden x-relay-target (embedded credentials)", { status: 403 }); + } + if (isPrivateHostname(targetUrl.hostname)) { + return new Response("forbidden x-relay-target (private/loopback host)", { status: 403 }); + } + const relayPath = request.headers.get("x-relay-path") || "/"; + const headers = new Headers(request.headers); + ["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach((h) => headers.delete(h)); + const init = { + method: request.method, + headers, + }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + init.duplex = "half"; + } + try { + const upstream = await fetch(target.replace(/\\/$/, "") + relayPath, init); + return new Response(upstream.body, { + status: upstream.status, + headers: upstream.headers, + }); + } catch (error) { + return new Response(JSON.stringify({ error: error && error.message ? error.message : "relay error" }), { + status: 502, + headers: { "content-type": "application/json" }, + }); + } + }, +}; +`; +} diff --git a/src/shared/validation/freeProxySchemas.ts b/src/shared/validation/freeProxySchemas.ts index 1b4fb64df896..ab91b72810b4 100644 --- a/src/shared/validation/freeProxySchemas.ts +++ b/src/shared/validation/freeProxySchemas.ts @@ -79,3 +79,33 @@ export const vercelDeploySchema = z.object({ .regex(/^[a-z0-9-]+$/, "Project name must be lowercase alphanumeric with hyphens") .default("omniroute-relay"), }); + +export const cloudflareDeploySchema = z.object({ + // Cloudflare Account ID is a 32-char lowercase hex string. Reject anything + // obviously malformed so users get clearer feedback than a Cloudflare 401/404. + accountId: z + .string() + .min(8, "Cloudflare Account ID looks too short") + .max(64) + .regex( + /^[a-f0-9]+$/, + "Cloudflare Account ID must be lowercase hex" + ), + // Cloudflare API tokens are opaque alphanumeric (40+ chars) — same alphabet + // we accept for Vercel tokens; constrain length to catch paste accidents. + apiToken: z + .string() + .min(20, "Cloudflare API token looks too short") + .max(200) + .regex( + /^[A-Za-z0-9_-]+$/, + "Cloudflare API token must contain only alphanumeric, underscore, or hyphen" + ), + projectName: z + .string() + .min(3) + .max(52) + .regex(/^[a-z0-9-]+$/, "Worker name must be lowercase alphanumeric with hyphens") + .default("omniroute-relay"), +}); + diff --git a/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts b/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts new file mode 100644 index 000000000000..6109a524523b --- /dev/null +++ b/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts @@ -0,0 +1,183 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Port of upstream decolua/9router PR #1360: Cloudflare Workers as proxy relay. +// +// Architecture mirrors the existing Vercel relay (same x-relay-target / +// x-relay-path / x-relay-auth header spec, same SSRF guard inlined into the +// worker, same fail-closed missing-relayAuth check). Only the deployment +// target changes: instead of POSTing to the Vercel /v13/deployments API, +// we PUT a Worker script to Cloudflare's accounts/{accountId}/workers/scripts +// API and enable the workers.dev subdomain. +// +// Coverage: +// 1. buildCloudflareWorkerScript(relayAuth) returns an Edge-runtime worker +// module whose source enforces the same x-relay-auth check as the +// Vercel relay (so a leaked workers.dev URL cannot be used as an +// open SSRF proxy by a third party). +// 2. proxyFetch's relay short-circuit treats type "cloudflare" the same +// way as "vercel" — uses buildVercelRelayHeaders + routes to the +// workers.dev origin, never the upstream target. +// 3. The proxyDispatcher / DB layer recognise "cloudflare" as a relay +// type (extractRelayAuth fires, dispatcher returns the worker URL). + +// --- Install the relay sink BEFORE importing the module under test. --- +type FetchCall = { input: unknown; init: RequestInit & { headers?: HeadersInit } }; +const relayCalls: FetchCall[] = []; +const realGlobalFetch = globalThis.fetch; + +const relaySink = (async (input: unknown, init: RequestInit = {}) => { + relayCalls.push({ input, init }); + return Response.json({ via: "cloudflare-relay" }); +}) as unknown as typeof globalThis.fetch; + +globalThis.fetch = relaySink; + +const proxyDispatcher = await import("../../open-sse/utils/proxyDispatcher.ts"); +const { buildVercelRelayHeaders, proxyConfigToUrl } = proxyDispatcher; +const proxyFetchMod = await import("../../open-sse/utils/proxyFetch.ts"); +const { proxyFetch, runWithProxyContext } = proxyFetchMod; + +const cfDeploy = await import("../../src/lib/proxyRelay/cloudflareWorkerScript.ts"); +const { buildCloudflareWorkerScript } = cfDeploy; + +test.after(() => { + globalThis.fetch = realGlobalFetch; +}); + +test.beforeEach(() => { + relayCalls.length = 0; +}); + +// -------------------------------------------------------------------------- +// 1. buildCloudflareWorkerScript — emitted worker source contract +// -------------------------------------------------------------------------- + +test("buildCloudflareWorkerScript embeds the supplied relayAuth literal", () => { + const src = buildCloudflareWorkerScript("a-very-specific-secret-token"); + assert.ok( + src.includes('"a-very-specific-secret-token"'), + "worker source must embed the relayAuth secret as a string literal" + ); +}); + +test("buildCloudflareWorkerScript rejects requests without a valid x-relay-auth header", () => { + // The worker source must enforce the same auth check as the Vercel relay: + // a 401 short-circuit when x-relay-auth does not match the embedded token. + // We don't run the worker here — we check the source contains the guard. + const src = buildCloudflareWorkerScript("the-secret"); + assert.ok( + /x-relay-auth/.test(src), + "worker source must reference the x-relay-auth header" + ); + assert.ok( + /401|Unauthorized/.test(src), + "worker source must short-circuit unauthorised requests with 401" + ); +}); + +test("buildCloudflareWorkerScript blocks loopback / RFC1918 / link-local hosts (SSRF guard)", () => { + // Mirrors the Vercel relay's inlined SSRF guard. A leaked workers.dev URL + // must not be usable to scan internal networks. + const src = buildCloudflareWorkerScript("tok"); + // The guard recognises private CIDRs / loopback by literal substrings in + // the inline function. These specific tokens are load-bearing. + assert.ok(/127\.0\.0\.1|localhost/.test(src), "blocks loopback hosts"); + assert.ok(/192\.168|10\.|172/.test(src), "blocks RFC1918 hosts"); + assert.ok(/169\.254|link-local|fe80/.test(src), "blocks link-local hosts"); +}); + +test("buildCloudflareWorkerScript uses ESM default-export fetch handler (Workers Modules format)", () => { + // Cloudflare's PUT /workers/scripts API expects a module-format worker + // (main_module = index.js, content-type application/javascript+module). + // The handler must be exposed as `export default { fetch }`. + const src = buildCloudflareWorkerScript("tok"); + assert.ok(/export\s+default/.test(src), "must be an ES module (export default)"); + assert.ok(/fetch\s*\(/.test(src), "must export a fetch handler"); +}); + +// -------------------------------------------------------------------------- +// 2. proxyFetch — cloudflare type takes the relay short-circuit +// -------------------------------------------------------------------------- + +const CLOUDFLARE_CTX = { + type: "cloudflare" as const, + host: "omniroute-relay.acme.workers.dev", + relayAuth: "live-cf-secret", +}; + +test("proxyFetch routes a cloudflare-type context through the relay endpoint with relay headers", async () => { + const response = await runWithProxyContext(CLOUDFLARE_CTX, () => + proxyFetch("https://api.anthropic.com/v1/messages?x=1", { + method: "POST", + headers: { "x-existing": "keep-me" }, + }) + ); + + assert.deepEqual(await response.json(), { via: "cloudflare-relay" }); + assert.equal(relayCalls.length, 1, "exactly one relay dispatch"); + const call = relayCalls[0]; + + // Rewritten to the workers.dev origin, NOT the upstream target. + assert.equal(call.input, "https://omniroute-relay.acme.workers.dev"); + + const sentHeaders = new Headers(call.init.headers); + assert.equal(sentHeaders.get("x-relay-target"), "https://api.anthropic.com"); + assert.equal(sentHeaders.get("x-relay-path"), "/v1/messages?x=1"); + assert.equal(sentHeaders.get("x-relay-auth"), "live-cf-secret"); + assert.equal(sentHeaders.get("x-existing"), "keep-me"); + assert.equal(call.init.method, "POST"); + assert.equal((call.init as { duplex?: string }).duplex, "half"); +}); + +test("proxyFetch throws (without dispatching) when a cloudflare context is missing relayAuth", async () => { + await assert.rejects( + runWithProxyContext({ type: "cloudflare", host: "x.workers.dev" }, () => + proxyFetch("https://api.anthropic.com/v1/messages", { method: "POST" }) + ), + /relay configuration error: missing relayAuth/ + ); + assert.equal(relayCalls.length, 0, "no relay dispatch when relayAuth is missing"); +}); + +test("the missing-relayAuth error message does not leak internal [ProxyFetch] diagnostics", async () => { + await runWithProxyContext({ type: "cloudflare", host: "x.workers.dev" }, async () => { + try { + await proxyFetch("https://api.anthropic.com/v1/messages", { method: "POST" }); + assert.fail("expected the relay branch to throw on missing relayAuth"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + assert.ok(!message.includes("[ProxyFetch]"), "no internal [ProxyFetch] label"); + assert.ok(!message.includes("at /"), "no stack-trace path leaked"); + } + }); +}); + +// -------------------------------------------------------------------------- +// 3. proxyConfigToUrl — cloudflare type yields the worker https origin +// -------------------------------------------------------------------------- + +test("proxyConfigToUrl returns the cloudflare worker URL (no HTTP-proxy dispatcher needed)", () => { + const url = proxyConfigToUrl({ + type: "cloudflare", + host: "omniroute-relay.acme.workers.dev", + }); + assert.equal(url, "https://omniroute-relay.acme.workers.dev"); +}); + +// -------------------------------------------------------------------------- +// 4. buildVercelRelayHeaders is shared (renamed-or-aliased? at minimum still +// works for cloudflare — same header spec). +// -------------------------------------------------------------------------- + +test("buildVercelRelayHeaders is the shared relay-header builder used for cloudflare too", () => { + const headers = buildVercelRelayHeaders( + "https://api.openai.com/v1/chat/completions", + "cf-tok" + ); + assert.deepEqual(headers, { + "x-relay-target": "https://api.openai.com", + "x-relay-path": "/v1/chat/completions", + "x-relay-auth": "cf-tok", + }); +}); From 5d9ecf6f8b0242a457880e285fe99fee870c71b5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:00:16 -0300 Subject: [PATCH 076/120] chore(quality): conserta base-red de release/v3.8.36 (gates + 7 testes + build MDX) (#4915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A base tinha base-red sistêmica herdada de PRs de outras sessões, bloqueando TODOS os PRs do ciclo (o TIA roda a suíte full em fail-safe p/ diffs hub). 4 Fast Quality Gates: - test-discovery (#4877): live-server-allowlist.test.ts em tests/unit/server/ (não-coletado) + vitest → nunca rodava. Convertido p/ node:test em tests/unit/security/. - any-budget:t11 (#4664): 3 explicit-any em tokenRefresh.ts tipados (sem crescer file-size). - docs-symbols (#4868): rotas inexistentes → /api/system/version e PUT /api/providers/{id} {isActive:false}. - docs-all fabricated-claim (#4868 + #4718): 5 bin/*.sh reais criados (rollback, snapshot-data, restore-data, restore-policies, cold-start-bench) + _ops-common.sh (snapshot VACUUM INTO, guards de confirmação/TTY, testes de contrato); NODE_EXTRA_CA_CERTS (env de runtime Node) na allowlist do checker. 7 testes unit base-red (de features alheias à quota): - oauth-providers-config (#4664): teste alinhado ao provider codebuddy-cn do registry. - antigravity-model-aliases (#4636): maxOutputTokens esperado 32769→16384 (cap intencional). - provider-request-capture #4091 (#4861): exemplo do teste trocado de mcp__ (que #4861 isenta de cloak por causa dos 400s de assimetria de histórico) para um tool de terceiro cloakável — preserva o invariante de #4091 SEM reverter #4861. - combo-error-response: convertido de vitest p/ node:test (era coletado pelo glob node:test e crashava); api/** e server/** removidos do vitest.config (config morta). Build MDX (dast-smoke, #4679): - docs/ops/RELEASE_GREEN.md não tinha frontmatter `title` → fumadocs-mdx rejeitava no webpack compile ("invalid frontmatter: title expected string"), quebrando o next build (e o deploy). Frontmatter title adicionado (único doc do collection sem ele). 17/17 Fast Quality Gates + suíte unit completa (17737 testes, 0 fail) + vitest verdes localmente. Co-authored-by: Diego Rodrigues de Sa e Souza --- bin/_ops-common.sh | 70 +++++++ bin/cold-start-bench.sh | 82 ++++++++ bin/restore-data.sh | 64 ++++++ bin/restore-policies.sh | 77 ++++++++ bin/rollback.sh | 102 ++++++++++ bin/snapshot-data.sh | 61 ++++++ config/quality/file-size-baseline.json | 3 +- docs/INCIDENT_RESPONSE.md | 10 +- docs/PERF_BUDGETS.md | 2 +- docs/ops/RELEASE_GREEN.md | 16 +- open-sse/services/tokenRefresh.ts | 6 +- scripts/check/check-fabricated-docs.mjs | 1 + tests/unit/antigravity-model-aliases.test.ts | 14 +- tests/unit/api/combo-error-response.test.ts | 66 +++---- tests/unit/oauth-providers-config.test.ts | 31 ++- tests/unit/ops-scripts.test.ts | 184 ++++++++++++++++++ ...-request-capture-toolname-map-4091.test.ts | 17 +- .../live-server-allowlist.test.ts | 75 +++---- vitest.config.ts | 2 - 19 files changed, 770 insertions(+), 113 deletions(-) create mode 100644 bin/_ops-common.sh create mode 100755 bin/cold-start-bench.sh create mode 100755 bin/restore-data.sh create mode 100755 bin/restore-policies.sh create mode 100755 bin/rollback.sh create mode 100755 bin/snapshot-data.sh create mode 100644 tests/unit/ops-scripts.test.ts rename tests/unit/{server => security}/live-server-allowlist.test.ts (61%) diff --git a/bin/_ops-common.sh b/bin/_ops-common.sh new file mode 100644 index 000000000000..820efccc303d --- /dev/null +++ b/bin/_ops-common.sh @@ -0,0 +1,70 @@ +# bin/_ops-common.sh — shared helpers for the OmniRoute ops runbook scripts. +# +# Sourced (not executed) by rollback.sh / snapshot-data.sh / restore-data.sh / +# restore-policies.sh / cold-start-bench.sh. The runbook context lives in +# docs/INCIDENT_RESPONSE.md and docs/PERF_BUDGETS.md. +# +# Path resolution mirrors the app (src/lib/db/core.ts): the SQLite store is +# $DATA_DIR/storage.sqlite and managed backups go to $DATA_DIR/db_backups +# (overridable via DB_BACKUPS_DIR), so snapshots created here are interchangeable +# with the ones the server writes on migrations. + +# Recompute the data-dir-derived paths. Called once on source, and again by +# scripts that accept a --data-dir override. +ops_set_data_dir() { + OMNIROUTE_DATA_DIR="$1" + OMNIROUTE_SQLITE="${OMNIROUTE_DATA_DIR}/storage.sqlite" + OMNIROUTE_BACKUPS_DIR="${DB_BACKUPS_DIR:-${OMNIROUTE_DATA_DIR}/db_backups}" +} +ops_set_data_dir "${DATA_DIR:-$HOME/.omniroute}" + +ops_log() { printf '[%s] %s\n' "${SCRIPT_NAME:-ops}" "$*" >&2; } +ops_die() { + printf '[%s] ERROR: %s\n' "${SCRIPT_NAME:-ops}" "$*" >&2 + exit 1 +} + +ops_require_cmd() { + command -v "$1" >/dev/null 2>&1 || ops_die "required command not found: $1" +} + +# ops_confirm "" — return 0 to proceed. Honors ASSUME_YES=1 (set by the +# --yes flag) and REFUSES a destructive action on a non-interactive stdin unless +# ASSUME_YES is set, so an unattended/CI invocation can never silently destroy data. +ops_confirm() { + local prompt="$1" reply + if [ "${ASSUME_YES:-0}" = "1" ]; then return 0; fi + if [ ! -t 0 ]; then + ops_die "refusing a destructive action without a TTY; pass --yes to proceed non-interactively" + fi + read -r -p "$prompt [y/N] " reply + case "$reply" in + [yY] | [yY][eE][sS]) return 0 ;; + *) return 1 ;; + esac +} + +# ops_find_snapshot — resolve a snapshot identifier (a snapshot dir name, +# a bare timestamp/sha, or an explicit path) to a directory containing +# storage.sqlite. Echoes the resolved dir or dies. +ops_find_snapshot() { + local id="$1" cand + [ -n "$id" ] || ops_die "snapshot id required (a timestamp/sha, dir name, or path)" + for cand in \ + "$id" \ + "$id/" \ + "$OMNIROUTE_BACKUPS_DIR/$id" \ + "$OMNIROUTE_BACKUPS_DIR/snapshot_$id"; do + if [ -f "${cand%/}/storage.sqlite" ]; then + printf '%s\n' "${cand%/}" + return 0 + fi + done + # Fall back to a prefix match against snapshot_* dirs (e.g. a short sha/date). + if [ -d "$OMNIROUTE_BACKUPS_DIR" ]; then + for cand in "$OMNIROUTE_BACKUPS_DIR"/snapshot_*"$id"*; do + [ -f "$cand/storage.sqlite" ] && { printf '%s\n' "$cand"; return 0; } + done + fi + ops_die "no snapshot matching '$id' under $OMNIROUTE_BACKUPS_DIR (run bin/snapshot-data.sh first)" +} diff --git a/bin/cold-start-bench.sh b/bin/cold-start-bench.sh new file mode 100755 index 000000000000..f879610712a9 --- /dev/null +++ b/bin/cold-start-bench.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# bin/cold-start-bench.sh — measure OmniRoute cold-start against the budgets in +# docs/PERF_BUDGETS.md §5 (container start → HTTP listening ≤ 800 ms; first warm +# TTFB ≤ 200 ms). Boots the server on a throwaway port, times until +# /api/health/ping answers 200, measures a warm request, and reports PASS/FAIL. +set -euo pipefail +SCRIPT_NAME="cold-start-bench" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_ops-common.sh" + +usage() { + cat <<'EOF' +Usage: bin/cold-start-bench.sh [--port ] [--start-cmd ""] [--url ] + [--listen-budget-ms ] [--ttfb-budget-ms ] [-h|--help] + +Boots OmniRoute, times cold-start to the first /api/health/ping 200, measures +warm TTFB, and compares against the PERF_BUDGETS.md §5 budgets +(listen ≤ 800 ms, TTFB ≤ 200 ms). Exits non-zero if a budget is exceeded. + +--url benches an already-running server instead of booting one (skips the boot +timing; only TTFB is measured). +EOF +} + +PORT="${PORT:-21987}" +START_CMD="" +BASE_URL="" +LISTEN_BUDGET_MS=800 +TTFB_BUDGET_MS=200 + +while [ $# -gt 0 ]; do + case "$1" in + --port) PORT="${2:?--port needs a value}"; shift 2 ;; + --start-cmd) START_CMD="${2:?--start-cmd needs a value}"; shift 2 ;; + --url) BASE_URL="${2:?--url needs a value}"; shift 2 ;; + --listen-budget-ms) LISTEN_BUDGET_MS="${2:?}"; shift 2 ;; + --ttfb-budget-ms) TTFB_BUDGET_MS="${2:?}"; shift 2 ;; + -h | --help) usage; exit 0 ;; + *) ops_die "unknown argument: $1 (see --help)" ;; + esac +done + +ops_require_cmd curl + +now_ms() { date +%s%3N; } # ms since epoch (GNU date / Linux) +ping_ok() { curl -fsS -o /dev/null --max-time 2 "$1/api/health/ping" 2>/dev/null; } + +SERVER_PID="" +cleanup() { [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true; } +trap cleanup EXIT + +if [ -n "$BASE_URL" ]; then + ops_log "benching already-running server at $BASE_URL (boot timing skipped)" + listen_ms="" +else + BASE_URL="http://127.0.0.1:$PORT" + [ -n "$START_CMD" ] || START_CMD="npm start -- --port $PORT" + ops_log "booting: $START_CMD" + start_ms="$(now_ms)" + # shellcheck disable=SC2086 + PORT="$PORT" $START_CMD >/tmp/omniroute-coldstart.log 2>&1 & + SERVER_PID="$!" + deadline=$(($(now_ms) + 30000)) + until ping_ok "$BASE_URL"; do + kill -0 "$SERVER_PID" 2>/dev/null || ops_die "server process exited during boot (see /tmp/omniroute-coldstart.log)" + [ "$(now_ms)" -gt "$deadline" ] && ops_die "server did not answer /api/health/ping within 30s" + sleep 0.05 + done + listen_ms=$(($(now_ms) - start_ms)) +fi + +ping_ok "$BASE_URL" || ops_die "server at $BASE_URL is not answering /api/health/ping" +ttfb_ms="$(curl -fsS -o /dev/null -w '%{time_starttransfer}' "$BASE_URL/api/health/ping" | awk '{printf "%d", $1 * 1000}')" + +fail=0 +if [ -n "$listen_ms" ]; then + echo "cold-start (start → listening): ${listen_ms} ms (budget ${LISTEN_BUDGET_MS} ms)" + [ "$listen_ms" -le "$LISTEN_BUDGET_MS" ] || { echo "FAIL: listen budget exceeded"; fail=1; } +fi +echo "warm TTFB: ${ttfb_ms} ms (budget ${TTFB_BUDGET_MS} ms)" +[ "$ttfb_ms" -le "$TTFB_BUDGET_MS" ] || { echo "FAIL: TTFB budget exceeded"; fail=1; } +[ "$fail" -eq 0 ] && echo "PASS: within cold-start budgets" +exit "$fail" diff --git a/bin/restore-data.sh b/bin/restore-data.sh new file mode 100755 index 000000000000..df907fb73254 --- /dev/null +++ b/bin/restore-data.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# bin/restore-data.sh — restore the OmniRoute SQLite data volume from a snapshot +# created by bin/snapshot-data.sh. Used by the data-layer incident runbook +# (docs/INCIDENT_RESPONSE.md §4.4) after stopping writers. +# +# Safety: takes a pre-restore snapshot of the CURRENT data, refuses to run +# unattended without --yes, and verifies the snapshot before overwriting. +set -euo pipefail +SCRIPT_NAME="restore-data" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_ops-common.sh" + +usage() { + cat <<'EOF' +Usage: bin/restore-data.sh [--data-dir ] [--yes|-y] [-h|--help] + +Restores storage.sqlite (and any sibling *.sqlite) from a snapshot. The current +data is first copied to $DB_BACKUPS_DIR/pre-restore_ as a safety net. + is a timestamp/sha, a snapshot dir name, or a path (see snapshot-data.sh). +Stop OmniRoute before running, and restart it afterwards. +EOF +} + +ID="" +while [ $# -gt 0 ]; do + case "$1" in + --yes | -y) ASSUME_YES=1; shift ;; + --data-dir) ops_set_data_dir "${2:?--data-dir needs a value}"; shift 2 ;; + -h | --help) usage; exit 0 ;; + -*) ops_die "unknown argument: $1 (see --help)" ;; + *) ID="$1"; shift ;; + esac +done + +[ -n "$ID" ] || ops_die "snapshot id required (see --help)" +snap="$(ops_find_snapshot "$ID")" +ops_log "restore source: $snap → $OMNIROUTE_DATA_DIR" +ops_confirm "Overwrite storage.sqlite at $OMNIROUTE_DATA_DIR from $snap?" || ops_die "aborted" + +# Pre-restore safety copy of the live data. +if [ -f "$OMNIROUTE_SQLITE" ]; then + safety="$OMNIROUTE_BACKUPS_DIR/pre-restore_$(date -u +%Y%m%dT%H%M%SZ)" + mkdir -p "$safety" + if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 "$OMNIROUTE_SQLITE" "VACUUM INTO '$safety/storage.sqlite'" \ + || cp -a "$OMNIROUTE_SQLITE" "$safety/storage.sqlite" + else + cp -a "$OMNIROUTE_SQLITE" "$safety/storage.sqlite" + fi + ops_log "current data saved to $safety" +fi + +mkdir -p "$OMNIROUTE_DATA_DIR" +# Drop stale WAL/SHM so the restored DB is authoritative, then copy in. +rm -f "$OMNIROUTE_SQLITE" "${OMNIROUTE_SQLITE}-wal" "${OMNIROUTE_SQLITE}-shm" +cp -a "$snap/storage.sqlite" "$OMNIROUTE_SQLITE" + +# Restore sibling DBs captured in the snapshot. +for f in "$snap"/*.sqlite; do + [ -e "$f" ] || continue + [ "$(basename "$f")" = "storage.sqlite" ] && continue + cp -a "$f" "$OMNIROUTE_DATA_DIR/" +done + +ops_log "restore complete — restart OmniRoute to pick up the restored data" diff --git a/bin/restore-policies.sh b/bin/restore-policies.sh new file mode 100755 index 000000000000..9cf4da03ef6f --- /dev/null +++ b/bin/restore-policies.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# bin/restore-policies.sh — restore ONLY the API-key policy tables from a +# snapshot, leaving request/audit/runtime state intact. Used by the auth-layer +# incident runbook (docs/INCIDENT_RESPONSE.md §4.3) when policies_active is empty +# but the rest of the database is healthy (so a full restore-data is overkill). +# +# "Policy" tables = api_key* definition tables (the key + its limits/allowed +# quotas/context sources), EXCLUDING usage counters and reset logs so a restore +# never rewinds live usage accounting. +set -euo pipefail +SCRIPT_NAME="restore-policies" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_ops-common.sh" + +usage() { + cat <<'EOF' +Usage: bin/restore-policies.sh [--data-dir ] [--yes|-y] [-h|--help] + +Replaces the API-key policy tables (api_key*, excluding *counter* / *_log*) in +the live DB with the copies from a snapshot, inside one transaction. The live DB +is snapshotted to $DB_BACKUPS_DIR/pre-policy-restore_ first. Requires sqlite3. +EOF +} + +ID="" +while [ $# -gt 0 ]; do + case "$1" in + --yes | -y) ASSUME_YES=1; shift ;; + --data-dir) ops_set_data_dir "${2:?--data-dir needs a value}"; shift 2 ;; + -h | --help) usage; exit 0 ;; + -*) ops_die "unknown argument: $1 (see --help)" ;; + *) ID="$1"; shift ;; + esac +done + +[ -n "$ID" ] || ops_die "snapshot id required (see --help)" +ops_require_cmd sqlite3 +snap="$(ops_find_snapshot "$ID")" +[ -f "$OMNIROUTE_SQLITE" ] || ops_die "no live DB at $OMNIROUTE_SQLITE (use restore-data.sh for a full restore)" + +# Policy definition tables present in BOTH the snapshot and the live DB. GLOB +# keeps `_` literal; we drop usage counters / logs so accounting isn't rewound. +readarray -t tables < <( + sqlite3 "$snap/storage.sqlite" \ + "SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'api_key*' \ + AND name NOT GLOB '*counter*' AND name NOT GLOB '*_log*' ORDER BY name;" +) +[ "${#tables[@]}" -gt 0 ] || ops_die "snapshot has no api_key* policy tables" + +ops_log "policy tables to restore: ${tables[*]}" +ops_confirm "Replace ${#tables[@]} policy table(s) in $OMNIROUTE_SQLITE from $snap?" || ops_die "aborted" + +# Safety snapshot of the live DB before mutating it. +safety="$OMNIROUTE_BACKUPS_DIR/pre-policy-restore_$(date -u +%Y%m%dT%H%M%SZ)" +mkdir -p "$safety" +sqlite3 "$OMNIROUTE_SQLITE" "VACUUM INTO '$safety/storage.sqlite'" +ops_log "live DB saved to $safety" + +# Replace each policy table inside a single transaction, attaching the snapshot +# (only tables that also exist in the live DB are touched). +sql="ATTACH DATABASE '$snap/storage.sqlite' AS snap; +PRAGMA foreign_keys=OFF; +BEGIN;" +for t in "${tables[@]}"; do + if [ -n "$(sqlite3 "$OMNIROUTE_SQLITE" "SELECT 1 FROM sqlite_master WHERE type='table' AND name='$t' LIMIT 1;")" ]; then + sql="$sql +DELETE FROM main.\"$t\"; +INSERT INTO main.\"$t\" SELECT * FROM snap.\"$t\";" + else + ops_log "skipping '$t' — not present in live DB" + fi +done +sql="$sql +COMMIT; +DETACH DATABASE snap;" + +printf '%s\n' "$sql" | sqlite3 "$OMNIROUTE_SQLITE" +ops_log "policies restored from $snap — restart OmniRoute to apply" diff --git a/bin/rollback.sh b/bin/rollback.sh new file mode 100755 index 000000000000..41d7bfcd436c --- /dev/null +++ b/bin/rollback.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# bin/rollback.sh — roll OmniRoute back to a previous release to mitigate a bad +# deploy. Used by the incident runbook (docs/INCIDENT_RESPONSE.md §3 / §4). +# +# Methods (auto-detected; override with --method): +# • npm — `npm install -g omniroute@` and, if PM2 manages it, +# `pm2 restart omniroute`. This is how the VPS deploy runs. +# • docker — re-tag the local image omniroute: to omniroute:prod and +# recreate the prod service from docker-compose.prod.yml. (That +# compose builds the `prod` tag locally rather than pulling a +# registry tag, so the versioned image must already exist locally.) +# With no , targets the highest published release strictly below the +# current package.json version. +set -euo pipefail +SCRIPT_NAME="rollback" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_ops-common.sh" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +usage() { + cat <<'EOF' +Usage: bin/rollback.sh [] [--method npm|docker] [--yes|-y] [-h|--help] + +Rolls OmniRoute back to (e.g. 3.8.35 or v3.8.35). With no version, +picks the highest published release below the current package.json version. +Auto-detects npm vs docker deployment; override with --method. +EOF +} + +VERSION="" +METHOD="" +while [ $# -gt 0 ]; do + case "$1" in + --yes | -y) ASSUME_YES=1; shift ;; + --method) METHOD="${2:?--method needs npm|docker}"; shift 2 ;; + -h | --help) usage; exit 0 ;; + -*) ops_die "unknown argument: $1 (see --help)" ;; + *) VERSION="${1#v}"; shift ;; + esac +done + +if [ -z "$METHOD" ]; then + if command -v docker >/dev/null 2>&1 && [ -f "$REPO_ROOT/docker-compose.prod.yml" ] \ + && docker compose -f "$REPO_ROOT/docker-compose.prod.yml" ps -q 2>/dev/null | grep -q .; then + METHOD="docker" + elif command -v npm >/dev/null 2>&1; then + METHOD="npm" + else + ops_die "no deploy method detected (no running prod compose, no npm) — pass --method npm|docker" + fi +fi + +# Resolve the previous published version when none was given. +if [ -z "$VERSION" ]; then + ops_require_cmd npm + ops_require_cmd node + current="$(node -p "require('$REPO_ROOT/package.json').version" 2>/dev/null || true)" + [ -n "$current" ] || ops_die "cannot read current version from package.json — pass " + VERSION="$(npm view omniroute versions --json 2>/dev/null | node -e ' + let s = ""; + process.stdin.on("data", (d) => (s += d)).on("end", () => { + let vs; + try { vs = JSON.parse(s); } catch { vs = []; } + if (!Array.isArray(vs)) vs = [vs]; + const ok = (v) => /^[0-9]+\.[0-9]+\.[0-9]+$/.test(v); + const cmp = (a, b) => { + const x = a.split(".").map(Number), y = b.split(".").map(Number); + return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; + }; + const cur = process.argv[1]; + const prev = vs.filter(ok).filter((v) => cmp(v, cur) < 0).sort(cmp).pop() || ""; + process.stdout.write(prev); + }); + ' "$current")" + [ -n "$VERSION" ] || ops_die "could not resolve the previous published version — pass explicitly" +fi + +ops_log "target: omniroute@$VERSION via $METHOD" +ops_confirm "Roll OmniRoute back to $VERSION via $METHOD?" || ops_die "aborted" + +case "$METHOD" in + npm) + ops_require_cmd npm + npm install -g "omniroute@$VERSION" + if command -v pm2 >/dev/null 2>&1 && pm2 jlist 2>/dev/null | grep -q '"name":"omniroute"'; then + pm2 restart omniroute --update-env + ops_log "pm2 restarted omniroute" + else + ops_log "installed omniroute@$VERSION — restart the service to apply (no PM2 'omniroute' process found)" + fi + ;; + docker) + ops_require_cmd docker + if ! docker image inspect "omniroute:$VERSION" >/dev/null 2>&1; then + ops_die "local image omniroute:$VERSION not found — build it from the $VERSION checkout first (this compose builds the 'prod' tag, it does not pull a registry tag)" + fi + docker tag "omniroute:$VERSION" omniroute:prod + docker compose -f "$REPO_ROOT/docker-compose.prod.yml" up -d --no-build + ops_log "recreated prod service from omniroute:$VERSION" + ;; + *) ops_die "unknown method: $METHOD (use npm or docker)" ;; +esac +ops_log "rollback to $VERSION complete" diff --git a/bin/snapshot-data.sh b/bin/snapshot-data.sh new file mode 100755 index 000000000000..f312a46b9aa2 --- /dev/null +++ b/bin/snapshot-data.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# bin/snapshot-data.sh — consistent point-in-time snapshot of the OmniRoute data +# volume (the SQLite store under $DATA_DIR). Used by the data-layer incident +# runbook (docs/INCIDENT_RESPONSE.md §4.4) before any restore. +# +# Output: a directory $DB_BACKUPS_DIR/snapshot_[_