From fcddea78989d31f763ae55bc9c04bce971b8d2da Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:18:59 -0300 Subject: [PATCH 01/47] deps: bump @humanfs/node from 0.16.7 to 0.16.8 (#12515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 7 bumps da raiz sobre o tip de release/v3.8.51: npm install reconciliou o lock sem diff (11/11 pacotes na versão alvo), typecheck:core e typecheck:noimplicit:core limpos, lint exit 0, e 149/149 testes nos 11 arquivos que exercitam zod diretamente. As falhas de CI do PR foram discriminadas como estado da base, não do bump. Obrigado, Dependabot. --- package-lock.json | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 270593d514..4b4739e580 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4578,29 +4578,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", From d6770bda0c02780d423555b62513c566ddf5bf42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:19:31 -0300 Subject: [PATCH 02/47] deps: bump @xmldom/xmldom from 0.9.10 to 0.9.12 (#12513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 7 bumps da raiz sobre o tip de release/v3.8.51: npm install reconciliou o lock sem diff (11/11 pacotes na versão alvo), typecheck:core e typecheck:noimplicit:core limpos, lint exit 0, e 149/149 testes nos 11 arquivos que exercitam zod diretamente. As falhas de CI do PR foram discriminadas como estado da base, não do bump. Obrigado, Dependabot. --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4b4739e580..6c29cc0847 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15123,9 +15123,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.9.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", - "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", "dev": true, "license": "MIT", "engines": { From a986ef2e2bd436e7e73a612bf13772eea918eff8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:19:49 -0300 Subject: [PATCH 03/47] deps: bump browserslist from 4.28.2 to 4.28.8 (#12396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 7 bumps da raiz sobre o tip de release/v3.8.51: npm install reconciliou o lock sem diff (11/11 pacotes na versão alvo), typecheck:core e typecheck:noimplicit:core limpos, lint exit 0, e 149/149 testes nos 11 arquivos que exercitam zod diretamente. As falhas de CI do PR foram discriminadas como estado da base, não do bump. Obrigado, Dependabot. --- package-lock.json | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6c29cc0847..308eb97730 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16266,9 +16266,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.13", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", - "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -16626,9 +16626,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -16646,11 +16646,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -17100,9 +17100,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -20018,9 +20018,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.375", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", - "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "dev": true, "license": "ISC" }, @@ -30776,9 +30776,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -38352,9 +38352,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { From df97d46f48f0bad4fd1f70845ca6989ed19b2539 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:20:07 -0300 Subject: [PATCH 04/47] deps: bump fast-uri from 3.1.5 to 3.1.7 (#12514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 7 bumps da raiz sobre o tip de release/v3.8.51: npm install reconciliou o lock sem diff (11/11 pacotes na versão alvo), typecheck:core e typecheck:noimplicit:core limpos, lint exit 0, e 149/149 testes nos 11 arquivos que exercitam zod diretamente. As falhas de CI do PR foram discriminadas como estado da base, não do bump. Obrigado, Dependabot. --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 308eb97730..ce1d92be0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21700,9 +21700,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index f019c45e90..da0d54b4da 100644 --- a/package.json +++ b/package.json @@ -475,7 +475,7 @@ "@babel/core": "^7.29.6", "hono": "^4.12.34", "@hono/node-server": "^2.0.5", - "fast-uri": "^3.1.5", + "fast-uri": "^3.1.7", "body-parser": "^2.3.0", "@yarnpkg/parsers": { "js-yaml": "^4.3.1" From fa64266e32692c3fb01d11e8d023149c354269b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:20:24 -0300 Subject: [PATCH 05/47] deps: bump qs from 6.15.2 to 6.16.0 (#12512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 7 bumps da raiz sobre o tip de release/v3.8.51: npm install reconciliou o lock sem diff (11/11 pacotes na versão alvo), typecheck:core e typecheck:noimplicit:core limpos, lint exit 0, e 149/149 testes nos 11 arquivos que exercitam zod diretamente. As falhas de CI do PR foram discriminadas como estado da base, não do bump. Obrigado, Dependabot. --- package-lock.json | 27 ++++++++++++++------------- package.json | 2 +- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index ce1d92be0a..6059d042ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34034,12 +34034,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -35739,14 +35740,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -35758,13 +35759,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" diff --git a/package.json b/package.json index da0d54b4da..af8d67e748 100644 --- a/package.json +++ b/package.json @@ -467,7 +467,7 @@ "sharp": "^0.35.4", "postcss": "^8.5.18", "ip-address": "^10.3.1", - "qs": "^6.15.2", + "qs": "^6.16.0", "uuid": "^14.0.2", "form-data": "^4.0.6", "vite": "^8.0.16", From 60ea5f8858a3740b11e9d844f8b7f133bad7b757 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:20:42 -0300 Subject: [PATCH 06/47] deps: bump the development group across 1 directory with 2 updates (#12347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 7 bumps da raiz sobre o tip de release/v3.8.51: npm install reconciliou o lock sem diff (11/11 pacotes na versão alvo), typecheck:core e typecheck:noimplicit:core limpos, lint exit 0, e 149/149 testes nos 11 arquivos que exercitam zod diretamente. As falhas de CI do PR foram discriminadas como estado da base, não do bump. Obrigado, Dependabot. --- package-lock.json | 110 +++++++++++++++++++++++----------------------- package.json | 2 +- 2 files changed, 56 insertions(+), 56 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6059d042ed..41a4c03dba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -148,7 +148,7 @@ "lint-staged": "^17.4.1", "lockfile-lint": "^5.0.1", "node-loader": "^2.1.0", - "opencode-ai": "1.18.23", + "opencode-ai": "1.18.25", "playwright-ctrf-json-reporter": "^0.0.29", "prettier": "^3.9.6", "promptfoo": "^0.122.1", @@ -14817,9 +14817,9 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", - "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { @@ -31451,9 +31451,9 @@ } }, "node_modules/opencode-ai": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.23.tgz", - "integrity": "sha512-3NkT0XINL7d0HYkTyGV1SPChHXhvRgKqNaTgKRTGb0TXUWszXA7MW/y3zMZw29y1AQuUDAzRvVYmQ9KGRQhroA==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.25.tgz", + "integrity": "sha512-pS4RKJ9eKwU7Dp5G5pdj1rhMnpG5APixXzfTKNoFqv9aFVI36Rnza2jESvKifxyPZlsA65MQB03WCArY0EK6mg==", "cpu": [ "arm64", "x64" @@ -31470,24 +31470,24 @@ "opencode": "bin/opencode.exe" }, "optionalDependencies": { - "opencode-darwin-arm64": "1.18.23", - "opencode-darwin-x64": "1.18.23", - "opencode-darwin-x64-baseline": "1.18.23", - "opencode-linux-arm64": "1.18.23", - "opencode-linux-arm64-musl": "1.18.23", - "opencode-linux-x64": "1.18.23", - "opencode-linux-x64-baseline": "1.18.23", - "opencode-linux-x64-baseline-musl": "1.18.23", - "opencode-linux-x64-musl": "1.18.23", - "opencode-windows-arm64": "1.18.23", - "opencode-windows-x64": "1.18.23", - "opencode-windows-x64-baseline": "1.18.23" + "opencode-darwin-arm64": "1.18.25", + "opencode-darwin-x64": "1.18.25", + "opencode-darwin-x64-baseline": "1.18.25", + "opencode-linux-arm64": "1.18.25", + "opencode-linux-arm64-musl": "1.18.25", + "opencode-linux-x64": "1.18.25", + "opencode-linux-x64-baseline": "1.18.25", + "opencode-linux-x64-baseline-musl": "1.18.25", + "opencode-linux-x64-musl": "1.18.25", + "opencode-windows-arm64": "1.18.25", + "opencode-windows-x64": "1.18.25", + "opencode-windows-x64-baseline": "1.18.25" } }, "node_modules/opencode-darwin-arm64": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.23.tgz", - "integrity": "sha512-QP9PjwpHtZoLVXw2WvUmPZecz7mWbQkT4t3K36B//fCaDG+zWa+SsztIeaW5azujNwwtUemLA5icE/zINng48Q==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.25.tgz", + "integrity": "sha512-W4dyMFtHBglWZ1SEooh3Ke9v1M9lv945Y58atb8e1yKII8YykJ8LknOFyKipYC028oPDO4IZc3GYGKbg9PCg2w==", "cpu": [ "arm64" ], @@ -31498,9 +31498,9 @@ ] }, "node_modules/opencode-darwin-x64": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.23.tgz", - "integrity": "sha512-R9nWP3edz/0FnEfwmuxtiWBB7bS4NtZCyCffJyiMlrbwdDC+bIXYrWxWXVrzaP1mJujs6g2MAwTCUSx/qpBhDw==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.25.tgz", + "integrity": "sha512-YYKrfeUSJhD7hZl+yNmayS51sDwxiE9o5XwrfgYSSie6sOyHFc9Ei13VBkVU6T+IJHhFhTahOFAwSDxggrAnGA==", "cpu": [ "x64" ], @@ -31511,9 +31511,9 @@ ] }, "node_modules/opencode-darwin-x64-baseline": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.23.tgz", - "integrity": "sha512-QGx6I/nFYur7qJ/Nx2L3fC4XYQt44cyDsm7p8twNA+cdjGX3ndnPbMdAl5ikdZyAfSMUGYK8VWY2JMxv0rmfjw==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.25.tgz", + "integrity": "sha512-rRgTaoTeIN2diL1e1HGZ48Zh4ynMDEB1jYjD76LaFUVzMwakEY1i7NvG8e/rbjRMDkgXIr6TwzCtIMcMOpLQMA==", "cpu": [ "x64" ], @@ -31524,9 +31524,9 @@ ] }, "node_modules/opencode-linux-arm64": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.23.tgz", - "integrity": "sha512-g1zDFhuE9FOYwjSGderlu69wfd4GQzS0xsDiIY11QUciuBmM6DrHqLvmhlLFsUVHYVnCPY1YeN1pq5ewE3x72Q==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.25.tgz", + "integrity": "sha512-PMvcpFpha3yAhaVCC0QbegHPxsEZ0FuQf+52PXvqQut1r3w1l1Pilor9tUA7TyCRa4UokACI90nTmKmtMnQBag==", "cpu": [ "arm64" ], @@ -31537,9 +31537,9 @@ ] }, "node_modules/opencode-linux-arm64-musl": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.23.tgz", - "integrity": "sha512-VyDkzUJfJgkx9h9RhazTW9xeTgSXBmVFPblsbPGBW9tR612f6gjQxfOfu4cpHnQHK1qjuW9ClzLKHWqv3EcJTA==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.25.tgz", + "integrity": "sha512-IwIPKmNwIjLshlSgjoRLKFwxxiLpZ5Y0zjv6r456RQtJKK62IbYGXkCm3AiSZ/lqGsu3XF+xn/Xza29ivgpgcg==", "cpu": [ "arm64" ], @@ -31553,9 +31553,9 @@ ] }, "node_modules/opencode-linux-x64": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.23.tgz", - "integrity": "sha512-5x9d1Cm/YtqzR6lAlNbgVprTQ3R3hx7qGTWCzm5l5u6lBNkhYTrhy2s8k25dxKKuxqZ9Kngqz9JYWvSVHy2Lmw==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.25.tgz", + "integrity": "sha512-bdRSJ6gbK/EnLNWxROOQYXFXiUeqeFxGz8DIO8LCqnii99A2OWFAyZ3Da5gpvfT1Yrp9/lYL55n/tM3ale5smg==", "cpu": [ "x64" ], @@ -31566,9 +31566,9 @@ ] }, "node_modules/opencode-linux-x64-baseline": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.23.tgz", - "integrity": "sha512-yUhBOXfTQour2JCdAkwD3DDqSnyxB0grefwdPqEhYmJHIkYxfJIIzyy6V//pyouvkE0XMouFtiuZXw8S6Wo0iQ==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.25.tgz", + "integrity": "sha512-+b0w7XyHx0XPQWHBk2JymXbXnyZQ2PjIPuu4a4QJgSUqGuGz1L2flA3wgpZVAWFUhrEIr9DFhBk3AkKKNgMuRw==", "cpu": [ "x64" ], @@ -31579,9 +31579,9 @@ ] }, "node_modules/opencode-linux-x64-baseline-musl": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.23.tgz", - "integrity": "sha512-c1DPxauhzAurlIBhJBr/rokDpc65l084T4qTl36gDDT9Xzc/Nk5Q5yMDaPm1DDI3WeHKDt11MDlxT5AjQW5gtw==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.25.tgz", + "integrity": "sha512-E2JUeOOSXPbG1cNOzxnqjqkd0a3+oFmwkbJe6bZ308CFgLWBFfVh0fF42HTCEqfK+yYbidpEkQuEkUgxq/11IA==", "cpu": [ "x64" ], @@ -31595,9 +31595,9 @@ ] }, "node_modules/opencode-linux-x64-musl": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.23.tgz", - "integrity": "sha512-t/5mlnTBZKdZpqKHwdwxlWqGakntauvMSmXtyJc17M7XJRmZaaGHtNSaSefbbYFIL4agoQCXTvIkhhyxOvr7zQ==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.25.tgz", + "integrity": "sha512-W15qTNDz1fsTzs1SkE6bB/gpIDBF3rwDbewUKdbyXD3dVs6umyugOql1T4u9n/gqWa/Z/VDURbn39VsejeSdbQ==", "cpu": [ "x64" ], @@ -31611,9 +31611,9 @@ ] }, "node_modules/opencode-windows-arm64": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.23.tgz", - "integrity": "sha512-QtJQcLU0yPz6on3jjks3f/EHgZuIDFw7FvAKu3wsHhL09NYDh7GczfRXDPRHa3NgqnU8dkB8p9mhqgaPRPogoQ==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.25.tgz", + "integrity": "sha512-GFp74pProoPwqktHMf+9wQ8fza1RvFt0RG0iRtTQnJ4VWVY62qEeVuJkH6ki9QXS270HXyqtxvF8AuHQzuVZlA==", "cpu": [ "arm64" ], @@ -31624,9 +31624,9 @@ ] }, "node_modules/opencode-windows-x64": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.23.tgz", - "integrity": "sha512-mMaIITuXzkNfjdcYL8uZaZuMDjulFyH/UCq9bxblam2mUZf9uWisoi5J6CXFsS/mkN7CZfTAt6PttSp4n3PH4g==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.25.tgz", + "integrity": "sha512-xW5wtSxWYbI7DcmQWMlNWIiDBdMJON1vDiEmVWo88R9tT/PaahOhWKgp7FoWDqJKf89jS3ZIzkqnkU3F2dio7A==", "cpu": [ "x64" ], @@ -31637,9 +31637,9 @@ ] }, "node_modules/opencode-windows-x64-baseline": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.23.tgz", - "integrity": "sha512-AqXsTKaPcDx3rrid5bLUwJbQ/3vr9rJ6fvOStIznTzwrbOgP8wy5G4jCoIzu6KB/WxGx/d1MrV4cGaJ73qnjBA==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.25.tgz", + "integrity": "sha512-/28bGRQwT+2JdGbtGaNr95tstgysiULEXtcvgNg7yLDxitqmSVgd8V8XRGS0UWDdfiWWMND9A9T5EAsbF1/xDQ==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index af8d67e748..924b7b6eeb 100644 --- a/package.json +++ b/package.json @@ -419,7 +419,7 @@ "lint-staged": "^17.4.1", "lockfile-lint": "^5.0.1", "node-loader": "^2.1.0", - "opencode-ai": "1.18.23", + "opencode-ai": "1.18.25", "playwright-ctrf-json-reporter": "^0.0.29", "prettier": "^3.9.6", "promptfoo": "^0.122.1", From fdee0ec2083c63fd9f4d187fdee8676ce89ed630 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:21:00 -0300 Subject: [PATCH 07/47] deps: bump the production group across 1 directory with 4 updates (#12399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 7 bumps da raiz sobre o tip de release/v3.8.51: npm install reconciliou o lock sem diff (11/11 pacotes na versão alvo), typecheck:core e typecheck:noimplicit:core limpos, lint exit 0, e 149/149 testes nos 11 arquivos que exercitam zod diretamente. As falhas de CI do PR foram discriminadas como estado da base, não do bump. Obrigado, Dependabot. --- package-lock.json | 60 ++++++++++++++++++++++++++++++----------------- package.json | 8 +++---- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 41a4c03dba..6f720491df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,7 @@ "lucide-react": "^1.33.0", "marked": "^18.0.11", "marked-terminal": "^7.3.0", - "material-symbols": "^0.46.0", + "material-symbols": "^0.47.0", "mermaid": "^11.17.2", "monaco-editor": "^0.56.0", "next": "16.3.3", @@ -62,7 +62,7 @@ "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", "omniglyph": "^1.4.0", - "open": "^11.0.1", + "open": "^11.0.2", "ora": "^9.4.1", "parse5": "^8.0.1", "pino": "^10.3.1", @@ -85,7 +85,7 @@ "sql.js": "^1.14.2", "tailwind-merge": "^3.6.0", "tiktoken": "^1.0.22", - "tsx": "^4.23.12", + "tsx": "^4.23.13", "turndown": "7.2.4", "turndown-plugin-gfm": "1.0.2", "undici": "^8.10.0", @@ -94,7 +94,7 @@ "ws": "^8.21.3", "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", - "zod": "^4.4.3", + "zod": "^4.5.4", "zustand": "^5.0.15" }, "bin": { @@ -19427,7 +19427,9 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, "license": "MIT", + "optional": true, "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" @@ -28350,9 +28352,9 @@ } }, "node_modules/material-symbols": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.46.0.tgz", - "integrity": "sha512-YxmTXwOhLOI6EupAwFfxFERbaDe61dG/tveOSy2HecndGKqvJ74WqXrrXLNWpIGDkk6TDpieuQPDS+hA7+z3Ig==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.47.0.tgz", + "integrity": "sha512-/Wt7QSv5Hih8EFj9ySHnnF+NAGajTMRLkfajj0j4MNCY3FB2QhGRg5xtnBs5bUE63mzVo7J5XbbIDr0HeITpHg==", "license": "Apache-2.0" }, "node_modules/math-intrinsics": { @@ -31399,16 +31401,16 @@ "optional": true }, "node_modules/open": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.1.tgz", - "integrity": "sha512-NzwMUB6C1D0+Kd+9iMS/H4k+Ck3cTX6Ckyfr/gAGlmvSE1LUQZnEZvWBi4PYmMwH/S5SMeTXnE+9uAz8uF+pWw==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.2.tgz", + "integrity": "sha512-RWqF+pBSkqecEvCKOn8QYhaNdRMJDZRIrlS/7rTDdLHaPcfXGCZ/h8zb413NfvdeAV0MR7T1yJcA34/q+CSm1Q==", "license": "MIT", "dependencies": { - "default-browser": "^5.4.0", + "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", - "powershell-utils": "^0.2.0", + "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" }, "engines": { @@ -31418,6 +31420,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open/node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/openai": { "version": "6.46.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.46.0.tgz", @@ -32980,9 +32998,9 @@ } }, "node_modules/powershell-utils": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz", - "integrity": "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", "license": "MIT", "engines": { "node": ">=20" @@ -37644,9 +37662,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.12", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", - "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -39917,9 +39935,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 924b7b6eeb..b3b33ae2f3 100644 --- a/package.json +++ b/package.json @@ -319,7 +319,7 @@ "lucide-react": "^1.33.0", "marked": "^18.0.11", "marked-terminal": "^7.3.0", - "material-symbols": "^0.46.0", + "material-symbols": "^0.47.0", "mermaid": "^11.17.2", "monaco-editor": "^0.56.0", "next": "16.3.3", @@ -327,7 +327,7 @@ "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", "omniglyph": "^1.4.0", - "open": "^11.0.1", + "open": "^11.0.2", "ora": "^9.4.1", "parse5": "^8.0.1", "pino": "^10.3.1", @@ -350,7 +350,7 @@ "sql.js": "^1.14.2", "tailwind-merge": "^3.6.0", "tiktoken": "^1.0.22", - "tsx": "^4.23.12", + "tsx": "^4.23.13", "turndown": "7.2.4", "turndown-plugin-gfm": "1.0.2", "undici": "^8.10.0", @@ -359,7 +359,7 @@ "ws": "^8.21.3", "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", - "zod": "^4.4.3", + "zod": "^4.5.4", "zustand": "^5.0.15" }, "optionalDependencies": { From 2a6d45abea0a70768402c896dfe69422e731144a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:21:44 -0300 Subject: [PATCH 08/47] deps: bump electron from 43.4.1 to 44.0.0 in /electron (#12217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump MAJOR electron 43.4.1 → 44.0.0 (Chromium 152, Node 24.18.1, V8 15.2), aprovado pelo operador após análise dos breaking changes contra o código real: - **Remoção dos builds 32-bit (Windows ia32, Linux armv7l)** — sem impacto: `electron/package.json` só declara alvos x64 e arm64. - **libEGL/libGLESv2 deixam de ser distribuídos (ANGLE estático)** — sem impacto: nenhuma referência em `electron/`, nos scripts de build ou no afterPack. - **`clipboard` deixa de ser exposto ao renderer** — sem impacto: sem uso no projeto. - **`net.request` passa a rejeitar `Sec-Fetch-Dest` document/frame/iframe/fencedframe sem `Sec-Fetch-Mode: navigate`** — sem impacto: `net.request`/`net.fetch` não são usados. - **`openAsHidden` e `wasOpenedAsHidden` removidos de `app.set/getLoginItemSettings()`** — usados em `electron/main.js:1116` e `electron/lib/windowLifecycle.js:7`, mas sem regressão em plataforma suportada: o caminho vivo do autostart oculto é `args: ["--hidden"]` combinado com a checagem `argv.includes("--hidden")`, que `shouldStartHidden()` avalia primeiro; Linux nem chega nessa API (usa `enableLinuxDesktopAutostart`). `openAsHidden` só funcionava em macOS ≤ 12, que esta própria versão deixa de suportar. - **macOS 12 (Monterey) sai do suporte** — é o único efeito voltado ao usuário. Registrado no CHANGELOG em PR de acompanhamento. O smoke de empacotamento (`electron-package-smoke`) não roda em PR para branch de release — `ci.yml` dispara em `main` — então a validação de empacotamento acontece no merge → main, que é o modelo do repositório. Um PR de acompanhamento remove o código morto de `openAsHidden`/`wasOpenedAsHidden`. Obrigado, Dependabot. --- electron/package-lock.json | 8 ++++---- electron/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/electron/package-lock.json b/electron/package-lock.json index 1aeabc7edc..7dbc37a13c 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -12,7 +12,7 @@ "electron-updater": "^6.8.9" }, "devDependencies": { - "electron": "^43.4.1", + "electron": "^44.0.0", "electron-builder": "^26.15.3" }, "engines": { @@ -1367,9 +1367,9 @@ } }, "node_modules/electron": { - "version": "43.4.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-43.4.1.tgz", - "integrity": "sha512-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA==", + "version": "44.0.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.0.0.tgz", + "integrity": "sha512-FkTqPrFPZYljdPI5b7KORGsJTd6FgUQDefl5MrU3Xz9R87pAj9JLreIjDqcRN8hJIkFHIou0o8kKzvcpT9qiRQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/electron/package.json b/electron/package.json index d4fd2bd1b2..1ed7410a4d 100644 --- a/electron/package.json +++ b/electron/package.json @@ -28,7 +28,7 @@ "electron-updater": "^6.8.9" }, "devDependencies": { - "electron": "^43.4.1", + "electron": "^44.0.0", "electron-builder": "^26.15.3" }, "overrides": { From 032adb0809246fcbc70376a4e2d3ad96c4ff21e7 Mon Sep 17 00:00:00 2001 From: backryun Date: Thu, 3 Sep 2026 19:49:42 +0900 Subject: [PATCH 09/47] feat(providers): refresh Z.ai Web models and browser transport (#12524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com #12524, #12538, #12277 e #12367 sobre o tip de release/v3.8.51: os quatro boardaram sem conflito (áreas disjuntas — zai-web, nvidia, clova, cursor/devin/fable). typecheck:core limpo, check:provider-consistency OK (272 entradas REGISTRY, 355 providers canônicos), check:known-symbols OK, e 305/305 nos testes tocados pelos quatro PRs. Os IDs de modelo adicionados foram conferidos individualmente. Obrigado, @backryun. --- .../providers/registry/zai-web/index.ts | 35 ++-- open-sse/executors/zai-web.ts | 9 +- .../executors/zai-web/browserAutomation.ts | 11 +- open-sse/executors/zai-web/protocol.ts | 106 ++++++---- open-sse/services/browserBackedChat.ts | 18 ++ open-sse/services/browserBackedChat/types.ts | 4 + tests/unit/executor-zai-web.test.ts | 197 ++++++++++-------- tests/unit/model-test-runner.test.ts | 2 +- .../zai-web-chat-endpoint-8014-probe.test.ts | 5 +- .../zai-web-models-discovery-7678.test.ts | 36 ++-- 10 files changed, 237 insertions(+), 186 deletions(-) diff --git a/open-sse/config/providers/registry/zai-web/index.ts b/open-sse/config/providers/registry/zai-web/index.ts index 98901daab9..59b2b61c31 100644 --- a/open-sse/config/providers/registry/zai-web/index.ts +++ b/open-sse/config/providers/registry/zai-web/index.ts @@ -14,30 +14,27 @@ export const zai_webProvider: RegistryEntry = { // Z.ai's visible "Tools" switch enables its internal VLM/MCP tools. It does // not accept caller-supplied OpenAI `tools`, which remains disabled here. models: [ + { + id: "glm-5.3-flash", + name: "GLM-5.3-Flash", + toolCalling: false, + supportsReasoning: true, + supportedThinkingEfforts: ["low", "high", "max"], + supportsVision: true, + }, + { + id: "glm-5.3", + name: "GLM-5.3", + toolCalling: false, + supportsReasoning: true, + supportedThinkingEfforts: ["low", "high", "max"], + }, { id: "glm-5.2", name: "GLM-5.2", toolCalling: false, supportsReasoning: true, - }, - { - id: "GLM-5.1", - name: "GLM-5.1", - toolCalling: false, - supportsReasoning: true, - }, - { - id: "GLM-5-Turbo", - name: "GLM-5-Turbo", - toolCalling: false, - supportsReasoning: true, - }, - { - id: "GLM-5v-Turbo", - name: "GLM-5V-Turbo", - toolCalling: false, - supportsReasoning: true, - supportsVision: true, + supportedThinkingEfforts: ["high", "max"], }, ], }; diff --git a/open-sse/executors/zai-web.ts b/open-sse/executors/zai-web.ts index 6e01aee98d..fb09e4ea82 100644 --- a/open-sse/executors/zai-web.ts +++ b/open-sse/executors/zai-web.ts @@ -5,8 +5,9 @@ * browser-issued CAPTCHA proof for chat completions. The browser transport is * the default; callers with a short-lived proof can use the direct HTTP path. * - * Completions go to /api/v2/chat/completions; the older unversioned - * /api/chat/completions path is stale and 404s model-independently (#8014). + * Completions go to /api/v2/chat/completions. Z.ai's CAPTCHA rejects true + * headless Chromium with F001, so the browser transport uses off-screen headed + * Chromium while retaining the shared browser pool. */ import { createHash, randomUUID } from "node:crypto"; import { BaseExecutor, type ExecuteInput } from "./base.ts"; @@ -67,6 +68,7 @@ export { parseZaiFrontendVersion, resolveZaiThinkingConfig, resolveZaiVlmConfig, + zaiUpstreamModelId, } from "./zai-web/protocol.ts"; export type { ZaiModelCapabilities, @@ -174,6 +176,7 @@ function buildZaiBrowserChatOptions(input: { userAgent: ZAI_USER_AGENT, locale: "en-US", timezone: "Asia/Seoul", + headless: false, inputSelector: "#chat-input", submitButtonSelector: '[aria-label="Send Message"] button:not([disabled])', submitButtonMode: "dom", @@ -243,7 +246,7 @@ function resolveZaiRequest( const modelId = (bodyObj.model as string) || model || ZAI_DEFAULT_MODEL; if (imageUrls.length > 0 && !getZaiModelCapabilities(modelId).vision) { return fail( - `Z.ai model ${unprefixedModelId(modelId)} does not accept image input; use GLM-5V-Turbo.` + `Z.ai model ${unprefixedModelId(modelId)} does not accept image input; use GLM-5.3-Flash.` ); } diff --git a/open-sse/executors/zai-web/browserAutomation.ts b/open-sse/executors/zai-web/browserAutomation.ts index 38924ada4d..a8aabccc33 100644 --- a/open-sse/executors/zai-web/browserAutomation.ts +++ b/open-sse/executors/zai-web/browserAutomation.ts @@ -23,14 +23,17 @@ async function runStage(name: string, action: () => Promise): Promise { const selector = page.locator('[aria-label="Select a model"]').first(); await selector.waitFor({ state: "visible", timeout: 10_000 }); - if ((await selector.innerText()).includes(modelName)) return; + if ((await selector.getByText(modelName, { exact: true }).count()) > 0) return; // The landing-page hero animation can remain above the already-visible // selector and make coordinate-based clicks time out. await selector.evaluate((element) => (element as HTMLElement).click()); const menu = page.locator('[role="menu"]').filter({ hasText: modelName }).first(); await menu.waitFor({ state: "visible", timeout: 5_000 }); - const modelButton = menu.locator("button").filter({ hasText: modelName }).first(); + const modelButton = menu + .getByText(modelName, { exact: true }) + .first() + .locator("xpath=ancestor::button[1]"); await modelButton.evaluate((element) => (element as HTMLElement).click()); await page .locator('[aria-label="Select a model"]') @@ -73,13 +76,13 @@ async function setZaiBrowserWebSearch(page: Page, enabled: boolean): Promise, effort: ZaiThinkingConfig["effort"] ): Promise { const effortButton = menu.locator("button").filter({ - hasText: effort === "high" ? "High" : "Max", + hasText: effort === "low" ? "Low" : effort === "high" ? "High" : "Max", }); if ((await effortButton.getAttribute("data-selected")) === "true") return; await runStage(`select ${effort}`, () => diff --git a/open-sse/executors/zai-web/protocol.ts b/open-sse/executors/zai-web/protocol.ts index 562ca85a7f..511e5d3aa9 100644 --- a/open-sse/executors/zai-web/protocol.ts +++ b/open-sse/executors/zai-web/protocol.ts @@ -7,8 +7,8 @@ import { normalizeCookie, sanitizeErrorMessage } from "../../utils/error.ts"; export const ZAI_BASE_URL = "https://chat.z.ai"; export const ZAI_NEW_CHAT_URL = `${ZAI_BASE_URL}/api/v1/chats/new`; export const ZAI_CHAT_URL = `${ZAI_BASE_URL}/api/v2/chat/completions`; -export const ZAI_DEFAULT_MODEL = "GLM-5.1"; -export const ZAI_DEFAULT_FE_VERSION = "prod-fe-1.1.79"; +export const ZAI_DEFAULT_MODEL = "glm-5.3"; +export const ZAI_DEFAULT_FE_VERSION = "prod-fe-1.1.92"; export const ZAI_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; export const ZAI_FE_VERSION_CACHE_TTL_MS = 15 * 60 * 1000; @@ -21,7 +21,7 @@ export interface NewChatRequest { userMessageId: string; } -export type ZaiReasoningEffort = "high" | "max"; +export type ZaiReasoningEffort = "low" | "high" | "max"; export interface ZaiThinkingConfig { enabled: boolean; @@ -61,12 +61,23 @@ const NO_ZAI_MODEL_CAPABILITIES: ZaiModelCapabilities = Object.freeze({ }); /** - * Verified against chat.z.ai/api/models (prod-fe-1.1.79). + * Verified against chat.z.ai/api/models (prod-fe-1.1.92). * `returnFc` is the site's internal function-call result capability; it is * distinct from accepting caller-supplied OpenAI `tools`. */ const ZAI_MODEL_CAPABILITIES: Record = { - "glm-5.2": { + "glm-5.3-flash": { + mcp: false, + reasoningEffort: true, + returnFc: true, + thinking: true, + vision: true, + vlmTools: false, + vlmWebSearch: false, + vlmWebsiteMode: false, + webSearch: true, + }, + "glm-5.3": { mcp: true, reasoningEffort: true, returnFc: true, @@ -77,9 +88,9 @@ const ZAI_MODEL_CAPABILITIES: Record = { vlmWebsiteMode: false, webSearch: true, }, - "glm-5.1": { + "glm-5.2": { mcp: true, - reasoningEffort: false, + reasoningEffort: true, returnFc: true, thinking: true, vision: false, @@ -88,28 +99,6 @@ const ZAI_MODEL_CAPABILITIES: Record = { vlmWebsiteMode: false, webSearch: true, }, - "glm-5-turbo": { - mcp: true, - reasoningEffort: false, - returnFc: true, - thinking: true, - vision: false, - vlmTools: false, - vlmWebSearch: false, - vlmWebsiteMode: false, - webSearch: true, - }, - "glm-5v-turbo": { - mcp: false, - reasoningEffort: false, - returnFc: true, - thinking: true, - vision: true, - vlmTools: true, - vlmWebSearch: true, - vlmWebsiteMode: true, - webSearch: true, - }, }; export function asRecord(value: unknown): Record | null { @@ -136,6 +125,7 @@ export function describeZaiBrowserFailure(result: { status: number; body: Buffer; observedPostUrls?: string[]; + observedPostResponses?: Array<{ url: string; status: number }>; timing: { captureResponseMs: number; totalMs: number }; }): string { const status = result.status > 0 ? String(result.status) : "no matching response"; @@ -144,10 +134,16 @@ export function describeZaiBrowserFailure(result: { result.observedPostUrls && result.observedPostUrls.length > 0 ? ` Observed POST targets: ${result.observedPostUrls.join(", ")}.` : ""; + const observedResponses = + result.observedPostResponses && result.observedPostResponses.length > 0 + ? ` Observed POST responses: ${result.observedPostResponses + .map(({ url, status }) => `${url} [${status}]`) + .join(", ")}.` + : ""; const detail = browserFailureDetail(result.body) || (result.status === 0 - ? `The page did not issue the expected authenticated chat completion request.${observed}` + ? `The page did not issue the expected authenticated chat completion request.${observed}${observedResponses}` : "The browser response body was empty."); return `Z.ai browser transport failed (${status}; ${timing}): ${detail}`; } @@ -307,17 +303,27 @@ export function unprefixedModelId(modelId: string): string { return modelId.trim().split("/").at(-1) || modelId.trim(); } -export function browserModelName(modelId: string): string { +/** Map OmniRoute's public Flash id to the opaque id used by chat.z.ai's wire API. */ +export function zaiUpstreamModelId(modelId: string): string { const unprefixed = unprefixedModelId(modelId); - if (unprefixed.toLowerCase() === "glm-5.2") return "GLM-5.2"; - if (unprefixed.toLowerCase() === "glm-5v-turbo") return "GLM-5V-Turbo"; - return unprefixed; + return unprefixed.toLowerCase() === "glm-5.3-flash" ? "x-preview-l" : unprefixed; +} + +function zaiCapabilityModelId(modelId: string): string { + const unprefixed = unprefixedModelId(modelId).toLowerCase(); + return unprefixed === "x-preview-l" ? "glm-5.3-flash" : unprefixed; +} + +export function browserModelName(modelId: string): string { + const normalized = zaiCapabilityModelId(modelId); + if (normalized === "glm-5.3-flash") return "GLM-5.3-Flash"; + if (normalized === "glm-5.3") return "GLM-5.3"; + if (normalized === "glm-5.2") return "GLM-5.2"; + return unprefixedModelId(modelId); } export function getZaiModelCapabilities(modelId: string): ZaiModelCapabilities { - return ( - ZAI_MODEL_CAPABILITIES[unprefixedModelId(modelId).toLowerCase()] ?? NO_ZAI_MODEL_CAPABILITIES - ); + return ZAI_MODEL_CAPABILITIES[zaiCapabilityModelId(modelId)] ?? NO_ZAI_MODEL_CAPABILITIES; } function getFeatureOption(body: Record, key: string): unknown { @@ -325,7 +331,7 @@ function getFeatureOption(body: Record, key: string): unknown { return asRecord(body.features)?.[key]; } -/** Resolve each model's Deep Think control; only GLM-5.2 accepts High/Max effort. */ +/** Resolve each model's Deep Think control using its currently exposed effort vocabulary. */ export function resolveZaiThinkingConfig( modelId: string, body: Record @@ -339,19 +345,26 @@ export function resolveZaiThinkingConfig( : typeof reasoning?.effort === "string" ? reasoning.effort.trim().toLowerCase() : ""; - const disabled = body.enable_thinking === false || rawEffort === "none" || rawEffort === "off"; + const supportsLowEffort = zaiCapabilityModelId(modelId) !== "glm-5.2"; const effort: ZaiReasoningEffort = - rawEffort === "low" || rawEffort === "medium" || rawEffort === "high" ? "high" : "max"; + rawEffort === "low" && supportsLowEffort + ? "low" + : rawEffort === "low" || rawEffort === "medium" || rawEffort === "high" + ? "high" + : "max"; return { supported, - enabled: supported && !disabled, + // The current GLM-5.3/5.2 consumer models expose effort selection but no + // non-thinking mode. Keep Deep Think enabled even when a generic client + // sends an off/none compatibility value. + enabled: supported, effort, effortSupported: capabilities.reasoningEffort, }; } -/** Resolve GLM-5V-Turbo's visible Web Search and Tools controls. */ +/** Resolve the selected model's visible Web Search and Tools controls. */ export function resolveZaiVlmConfig(modelId: string, body: Record): ZaiVlmConfig { const capabilities = getZaiModelCapabilities(modelId); const toolsOption = getFeatureOption(body, "vlm_tools_enable"); @@ -425,7 +438,7 @@ export function buildZaiCompletionUrl(input: { hostname: "chat.z.ai", protocol: "https:", referrer: "", - title: "Z.ai - Advanced AI Chatbot & Agent powered by GLM-5.2", + title: "Z.ai - Advanced AI Chatbot & Agent powered by GLM-5.3", timezone_offset: "0", local_time: now.toISOString(), utc_time: now.toUTCString(), @@ -448,13 +461,14 @@ export function buildZaiNewChatBody( ): NewChatRequest { const prompt = latestUserPrompt(messages); const userMessageId = randomUUID(); + const upstreamModelId = zaiUpstreamModelId(modelId); return { userMessageId, payload: { chat: { id: "", title: "New Chat", - models: [modelId], + models: [upstreamModelId], params: {}, history: { messages: { @@ -465,7 +479,7 @@ export function buildZaiNewChatBody( role: "user", content: prompt, timestamp: Math.floor(Date.now() / 1000), - models: [modelId], + models: [upstreamModelId], }, }, currentId: userMessageId, @@ -530,7 +544,7 @@ export function buildZaiRequestBody(input: { } return { stream: true, - model: input.modelId, + model: zaiUpstreamModelId(input.modelId), messages: foldMessages(input.messages), signature_prompt: input.prompt, params, diff --git a/open-sse/services/browserBackedChat.ts b/open-sse/services/browserBackedChat.ts index 4b3c7078e7..fa51a9c266 100644 --- a/open-sse/services/browserBackedChat.ts +++ b/open-sse/services/browserBackedChat.ts @@ -236,6 +236,7 @@ export async function browserBackedChat( userAgent, locale, timezone, + headless, inputSelector, submitButtonSelector, submitButtonMode = "playwright", @@ -257,11 +258,13 @@ export async function browserBackedChat( userAgent, locale, timezone, + headless, }); const acquireContextMs = Date.now() - tAcquireStart; const page = await openPage(pooled); const observedPostUrls: string[] = []; + const observedPostResponses: Array<{ url: string; status: number }> = []; page.on("request", (request) => { if (request.method() !== "POST") return; try { @@ -273,6 +276,19 @@ export async function browserBackedChat( // Ignore malformed/non-HTTP request URLs. } }); + page.on("response", (response) => { + if (response.request().method() !== "POST") return; + try { + const url = new URL(response.url()); + if (!url.hostname.endsWith(chatUrlMatchDomain)) return; + observedPostResponses.push({ + url: `${url.origin}${url.pathname}`, + status: response.status(), + }); + } catch { + // Ignore malformed/non-HTTP response URLs. + } + }); try { const tNavStart = Date.now(); await withAbort( @@ -379,6 +395,7 @@ export async function browserBackedChat( body, isStealth: pooled.isStealth, observedPostUrls, + observedPostResponses, timing: { acquireContextMs, navigateMs, @@ -404,6 +421,7 @@ export async function browserBackedChat( body, isStealth: pooled.isStealth, observedPostUrls, + observedPostResponses, timing: { acquireContextMs, navigateMs: 0, diff --git a/open-sse/services/browserBackedChat/types.ts b/open-sse/services/browserBackedChat/types.ts index c90fb3b80e..3a197be16c 100644 --- a/open-sse/services/browserBackedChat/types.ts +++ b/open-sse/services/browserBackedChat/types.ts @@ -29,6 +29,8 @@ export interface BrowserBackedChatRequest { locale?: string; /** Browser IANA timezone. Defaults to America/New_York. */ timezone?: string; + /** Launch a headed browser when the provider rejects true headless mode. */ + headless?: boolean; /** Selector for the provider chat input. */ inputSelector: string; /** Optional selector for the provider submit button. */ @@ -64,6 +66,8 @@ export interface BrowserBackedChatResult { isStealth: boolean; /** Sanitized POST targets observed while submitting. */ observedPostUrls?: string[]; + /** Sanitized POST response targets and statuses observed while submitting. */ + observedPostResponses?: Array<{ url: string; status: number }>; timing: { acquireContextMs: number; navigateMs: number; diff --git a/tests/unit/executor-zai-web.test.ts b/tests/unit/executor-zai-web.test.ts index aa26418f9c..ec77c94f5e 100644 --- a/tests/unit/executor-zai-web.test.ts +++ b/tests/unit/executor-zai-web.test.ts @@ -30,7 +30,7 @@ function installZaiFetch( const value = String(url); if (value === ZAI_HOME_URL) { return new Response( - '' + '' ); } if (value === ZAI_NEW_CHAT_URL) { @@ -90,13 +90,18 @@ describe("ZaiWebExecutor", () => { "Z.ai browser transport failed (502; capture 30001ms, total 33412ms): " + "browserBackedChat failed: response.body unavailable" ); - assert.match( + assert.equal( mod.describeZaiBrowserFailure({ status: 0, body: Buffer.alloc(0), + observedPostUrls: ["https://chat.z.ai/api/v1/chats/new"], + observedPostResponses: [{ url: "https://chat.z.ai/api/v1/chats/new", status: 200 }], timing: { captureResponseMs: 30_000, totalMs: 33_000 }, }), - /no matching response.*did not issue the expected authenticated chat completion request/ + "Z.ai browser transport failed (no matching response; capture 30000ms, total 33000ms): " + + "The page did not issue the expected authenticated chat completion request. " + + "Observed POST targets: https://chat.z.ai/api/v1/chats/new. " + + "Observed POST responses: https://chat.z.ai/api/v1/chats/new [200]." ); }); @@ -128,9 +133,9 @@ describe("ZaiWebExecutor", () => { it("parses the deployed frontend version from the homepage asset path", () => { assert.equal( mod.parseZaiFrontendVersion( - "https://z-cdn.chatglm.cn/z-ai/frontend/prod-fe-1.1.79/assets/index.js" + "https://z-cdn.chatglm.cn/z-ai/frontend/prod-fe-1.1.92/assets/index.js" ), - "prod-fe-1.1.79" + "prod-fe-1.1.92" ); assert.equal(mod.parseZaiFrontendVersion(""), null); }); @@ -211,86 +216,87 @@ describe("ZaiWebExecutor", () => { ]); }); - it("enables Deep Think for every public model and limits effort to GLM-5.2", () => { - assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.2", {}), { + it("maps the three public models to their current Deep Think effort vocabularies", () => { + assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.3-flash", { reasoning_effort: "low" }), { supported: true, enabled: true, - effort: "max", + effort: "low", effortSupported: true, }); - assert.deepEqual(mod.resolveZaiThinkingConfig("zw/glm-5.2", { reasoning_effort: "medium" }), { + assert.deepEqual(mod.resolveZaiThinkingConfig("zw/glm-5.3", { reasoning_effort: "medium" }), { supported: true, enabled: true, effort: "high", effortSupported: true, }); - assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.2", { reasoning: { effort: "high" } }), { + assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.3", { reasoning: { effort: "high" } }), { supported: true, enabled: true, effort: "high", effortSupported: true, }); - assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.2", { reasoning_effort: "off" }), { - supported: true, - enabled: false, - effort: "max", - effortSupported: true, - }); - assert.deepEqual(mod.resolveZaiThinkingConfig("GLM-5.1", { reasoning_effort: "max" }), { + assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.3", { reasoning_effort: "off" }), { supported: true, enabled: true, effort: "max", - effortSupported: false, + effortSupported: true, + }); + assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.3", { enable_thinking: false }), { + supported: true, + enabled: true, + effort: "max", + effortSupported: true, + }); + assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.2", { reasoning_effort: "low" }), { + supported: true, + enabled: true, + effort: "high", + effortSupported: true, }); }); - it("maps GLM-5V-Turbo vision and internal VLM controls from live capabilities", () => { - assert.deepEqual(mod.getZaiModelCapabilities("zw/GLM-5v-Turbo"), { + it("maps GLM-5.3-Flash vision and web controls from live capabilities", () => { + assert.deepEqual(mod.getZaiModelCapabilities("zw/glm-5.3-flash"), { mcp: false, - reasoningEffort: false, + reasoningEffort: true, returnFc: true, thinking: true, vision: true, - vlmTools: true, - vlmWebSearch: true, - vlmWebsiteMode: true, + vlmTools: false, + vlmWebSearch: false, + vlmWebsiteMode: false, webSearch: true, }); - assert.deepEqual(mod.resolveZaiVlmConfig("GLM-5v-Turbo", {}), { - toolsEnabled: true, - webSearchEnabled: true, - websiteModeEnabled: true, + assert.deepEqual(mod.getZaiModelCapabilities("x-preview-l"), { + mcp: false, + reasoningEffort: true, + returnFc: true, + thinking: true, + vision: true, + vlmTools: false, + vlmWebSearch: false, + vlmWebsiteMode: false, + webSearch: true, }); - assert.deepEqual( - mod.resolveZaiVlmConfig("GLM-5v-Turbo", { - features: { - vlm_tools_enable: false, - vlm_web_search_enable: false, - vlm_website_mode: false, - }, - }), - { - toolsEnabled: false, - webSearchEnabled: false, - websiteModeEnabled: true, - } - ); - assert.deepEqual(mod.resolveZaiVlmConfig("GLM-5.1", {}), { + assert.deepEqual(mod.resolveZaiVlmConfig("glm-5.3-flash", { web_search: true }), { + toolsEnabled: false, + webSearchEnabled: true, + websiteModeEnabled: false, + }); + assert.deepEqual(mod.resolveZaiVlmConfig("glm-5.3", {}), { toolsEnabled: false, webSearchEnabled: false, websiteModeEnabled: false, }); - assert.deepEqual(mod.resolveZaiVlmConfig("GLM-5.1", { web_search: true }), { - toolsEnabled: false, - webSearchEnabled: true, - websiteModeEnabled: false, - }); + assert.equal(mod.zaiUpstreamModelId("zai-web/glm-5.3-flash"), "x-preview-l"); + assert.equal(mod.zaiUpstreamModelId("zai-web/glm-5.3"), "glm-5.3"); + assert.equal(mod.getZaiModelCapabilities("GLM-5.1").thinking, false); }); it("returns a credential error when no session credential is provided", async () => { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "GLM-5.1", + model: "glm-5.3", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "" }, @@ -313,7 +319,6 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "glm-5.2", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: TEST_TOKEN }, @@ -322,8 +327,10 @@ describe("ZaiWebExecutor", () => { const completion = await result.response.json(); assert.equal(completion.choices[0].message.content, "Browser"); + assert.equal(completion.model, "glm-5.3"); assert.equal(capturedRequest?.localStorage?.token, TEST_TOKEN); assert.equal(capturedRequest?.localStorageOrigin, "https://chat.z.ai"); + assert.equal(capturedRequest?.headless, false); assert.equal(capturedRequest?.inputSelector, "#chat-input"); assert.equal( capturedRequest?.submitButtonSelector, @@ -331,7 +338,7 @@ describe("ZaiWebExecutor", () => { ); assert.equal(capturedRequest?.submitButtonMode, "dom"); assert.equal(capturedRequest?.userMessage, "hi"); - assert.match(capturedRequest?.chatPageUrl ?? "", /model=GLM-5\.2/); + assert.match(capturedRequest?.chatPageUrl ?? "", /model=GLM-5\.3/); assert.equal(typeof capturedRequest?.beforeSubmit, "function"); assert.equal(result.headers["X-OmniRoute-Transport"], "browser"); assert.equal(result.transformedBody.browser_backed, true); @@ -342,7 +349,7 @@ describe("ZaiWebExecutor", () => { } }); - it("configures GLM-5V-Turbo controls on the browser transport", async () => { + it("configures GLM-5.3-Flash on the browser transport", async () => { let capturedRequest: BrowserBackedChatRequest | null = null; browserChat.__setBrowserBackedChatOverrideForTesting(async (request) => { capturedRequest = request; @@ -352,8 +359,8 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "GLM-5v-Turbo", - body: { messages: [{ role: "user", content: "use the model tools" }] }, + model: "glm-5.3-flash", + body: { messages: [{ role: "user", content: "use flash" }] }, stream: false, credentials: { apiKey: TEST_TOKEN }, signal: null, @@ -361,19 +368,19 @@ describe("ZaiWebExecutor", () => { const completion = await result.response.json(); assert.equal(completion.choices[0].message.content, "VLM"); - assert.match(capturedRequest?.chatPageUrl ?? "", /model=GLM-5V-Turbo/); + assert.match(capturedRequest?.chatPageUrl ?? "", /model=GLM-5\.3-Flash/); assert.equal(typeof capturedRequest?.beforeSubmit, "function"); assert.equal(result.transformedBody.enable_thinking, true); - assert.equal(result.transformedBody.vlm_tools_enable, true); - assert.equal(result.transformedBody.vlm_web_search_enable, true); - assert.equal(result.transformedBody.vlm_website_mode, true); - assert.equal("reasoning_effort" in result.transformedBody, false); + assert.equal(result.transformedBody.reasoning_effort, "max"); + assert.equal(result.transformedBody.vlm_tools_enable, false); + assert.equal(result.transformedBody.vlm_web_search_enable, false); + assert.equal(result.transformedBody.vlm_website_mode, false); } finally { browserChat.__resetBrowserBackedChatOverrideForTesting(); } }); - it("uploads GLM-5V-Turbo image input through the authenticated browser page", async () => { + it("uploads GLM-5.3-Flash image input through the authenticated browser page", async () => { let capturedRequest: BrowserBackedChatRequest | null = null; browserChat.__setBrowserBackedChatOverrideForTesting(async (request) => { capturedRequest = request; @@ -383,7 +390,7 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "GLM-5v-Turbo", + model: "glm-5.3-flash", body: { messages: [ { @@ -445,7 +452,7 @@ describe("ZaiWebExecutor", () => { assert.equal(result.response.status, 400); const parsed = await result.response.json(); - assert.match(parsed.error.message, /use GLM-5V-Turbo/); + assert.match(parsed.error.message, /use GLM-5\.3-Flash/); }); it("creates a chat, signs the v2 request, and forwards the CAPTCHA proof", async () => { @@ -461,9 +468,9 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "GLM-5.1", + model: "glm-5.3", body: { - model: "GLM-5.1", + model: "glm-5.3", messages: [{ role: "user", content: "hello" }], temperature: 0.4, web_search: true, @@ -477,7 +484,7 @@ describe("ZaiWebExecutor", () => { const newChatHeaders = capture.newChatInit?.headers as Record; assert.equal(newChatHeaders.Authorization, `Bearer ${TEST_TOKEN}`); const newChatBody = JSON.parse(String(capture.newChatInit?.body)); - assert.deepEqual(newChatBody.chat.models, ["GLM-5.1"]); + assert.deepEqual(newChatBody.chat.models, ["glm-5.3"]); assert.equal(newChatBody.chat.history.currentId.length, 36); assert.equal(newChatBody.chat.enable_thinking, true); assert.equal(newChatBody.chat.auto_web_search, true); @@ -494,11 +501,11 @@ describe("ZaiWebExecutor", () => { const headers = capture.completionInit?.headers as Record; assert.equal(headers.Authorization, `Bearer ${TEST_TOKEN}`); - assert.equal(headers["X-FE-Version"], "prod-fe-1.1.79"); + assert.equal(headers["X-FE-Version"], "prod-fe-1.1.92"); assert.match(headers["X-Signature"], /^[a-f0-9]{64}$/); const parsedBody = JSON.parse(String(capture.completionInit?.body)); - assert.equal(parsedBody.model, "GLM-5.1"); + assert.equal(parsedBody.model, "glm-5.3"); assert.equal(parsedBody.stream, true); assert.deepEqual(parsedBody.messages, [{ role: "user", content: "hello" }]); assert.equal(parsedBody.signature_prompt, "hello"); @@ -508,7 +515,7 @@ describe("ZaiWebExecutor", () => { assert.equal(parsedBody.features.web_search, false); assert.equal(parsedBody.features.auto_web_search, true); assert.equal(parsedBody.features.enable_thinking, true); - assert.equal("reasoning_effort" in parsedBody.features, false); + assert.equal(parsedBody.features.reasoning_effort, "max"); assert.equal(result.headers.Authorization, "Bearer [REDACTED]"); assert.equal(result.transformedBody.captcha_verify_param, "[REDACTED]"); } finally { @@ -516,7 +523,7 @@ describe("ZaiWebExecutor", () => { } }); - it("sends GLM-5.2 Deep Think High through the direct request path", async () => { + it("sends GLM-5.3 Deep Think Low through the direct request path", async () => { const capture: ZaiFetchCapture = {}; const originalFetch = installZaiFetch( () => @@ -529,36 +536,36 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); await executor.execute({ - model: "glm-5.2", + model: "glm-5.3", body: { - model: "glm-5.2", + model: "glm-5.3", messages: [{ role: "user", content: "think carefully" }], - reasoning_effort: "high", + reasoning_effort: "low", }, stream: false, credentials: { apiKey: TEST_CREDENTIAL }, signal: null, }); - // #8014: completions must target the versioned v2 path. The query string + // The query string // carries the per-request signature payload, so match the endpoint prefix. assert.ok( String(capture.completionUrl).startsWith("https://chat.z.ai/api/v2/chat/completions?"), - `expected the v2 completions endpoint, got ${capture.completionUrl}` + `expected the current completions endpoint, got ${capture.completionUrl}` ); const newChatBody = JSON.parse(String(capture.newChatInit?.body)); assert.equal(newChatBody.chat.enable_thinking, true); - assert.equal(newChatBody.chat.reasoning_effort, "high"); + assert.equal(newChatBody.chat.reasoning_effort, "low"); const completionBody = JSON.parse(String(capture.completionInit?.body)); assert.equal(completionBody.features.enable_thinking, true); - assert.equal(completionBody.features.reasoning_effort, "high"); + assert.equal(completionBody.features.reasoning_effort, "low"); } finally { globalThis.fetch = originalFetch; } }); - it("sends GLM-5V-Turbo VLM tools and web-search flags through the direct path", async () => { + it("maps GLM-5.3-Flash to its opaque wire id on the direct path", async () => { const capture: ZaiFetchCapture = {}; const originalFetch = installZaiFetch( () => @@ -571,10 +578,11 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); await executor.execute({ - model: "GLM-5v-Turbo", + model: "glm-5.3-flash", body: { - model: "GLM-5v-Turbo", - messages: [{ role: "user", content: "inspect this image" }], + model: "glm-5.3-flash", + messages: [{ role: "user", content: "answer quickly" }], + web_search: true, }, stream: false, credentials: { apiKey: TEST_CREDENTIAL }, @@ -582,19 +590,26 @@ describe("ZaiWebExecutor", () => { }); const newChatBody = JSON.parse(String(capture.newChatInit?.body)); + assert.deepEqual(newChatBody.chat.models, ["x-preview-l"]); + assert.deepEqual( + newChatBody.chat.history.messages[newChatBody.chat.history.currentId].models, + ["x-preview-l"] + ); assert.equal(newChatBody.chat.enable_thinking, true); assert.equal(newChatBody.chat.auto_web_search, true); - assert.equal(newChatBody.chat.extra.vlm_tools_enable, true); - assert.equal(newChatBody.chat.extra.vlm_web_search_enable, true); - assert.equal(newChatBody.chat.extra.vlm_website_mode, true); + assert.equal(newChatBody.chat.reasoning_effort, "max"); + assert.equal(newChatBody.chat.extra.vlm_tools_enable, false); + assert.equal(newChatBody.chat.extra.vlm_web_search_enable, false); + assert.equal(newChatBody.chat.extra.vlm_website_mode, false); const completionBody = JSON.parse(String(capture.completionInit?.body)); + assert.equal(completionBody.model, "x-preview-l"); assert.equal(completionBody.features.enable_thinking, true); - assert.equal(completionBody.features.auto_web_search, false); - assert.equal(completionBody.features.vlm_tools_enable, true); - assert.equal(completionBody.features.vlm_web_search_enable, true); - assert.equal(completionBody.features.vlm_website_mode, true); - assert.equal("reasoning_effort" in completionBody.features, false); + assert.equal(completionBody.features.reasoning_effort, "max"); + assert.equal(completionBody.features.auto_web_search, true); + assert.equal(completionBody.features.vlm_tools_enable, false); + assert.equal(completionBody.features.vlm_web_search_enable, false); + assert.equal(completionBody.features.vlm_website_mode, false); } finally { globalThis.fetch = originalFetch; } @@ -619,7 +634,7 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "GLM-5.1", + model: "glm-5.3", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: TEST_CREDENTIAL }, @@ -651,7 +666,7 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "GLM-5.1", + model: "glm-5.3", body: { messages: [{ role: "user", content: "hi" }] }, stream: true, credentials: { apiKey: TEST_CREDENTIAL }, @@ -673,7 +688,7 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "GLM-5.1", + model: "glm-5.3", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: TEST_CREDENTIAL }, diff --git a/tests/unit/model-test-runner.test.ts b/tests/unit/model-test-runner.test.ts index 7a19e67482..c717ea0bb0 100644 --- a/tests/unit/model-test-runner.test.ts +++ b/tests/unit/model-test-runner.test.ts @@ -314,7 +314,7 @@ test("resolveModelTestTimeoutMs defaults ordinary model checks to 30 seconds", ( test("resolveModelTestTimeoutMs gives zai-web checks up to 60 seconds", () => { assert.equal(resolveModelTestTimeoutMs("zai-web", "glm-5.2", 30_000), 60_000); - assert.equal(resolveModelTestTimeoutMs("zai-web", "zai-web/GLM-5V-Turbo", 90_000), 90_000); + assert.equal(resolveModelTestTimeoutMs("zai-web", "zai-web/glm-5.3-flash", 90_000), 90_000); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/zai-web-chat-endpoint-8014-probe.test.ts b/tests/unit/zai-web-chat-endpoint-8014-probe.test.ts index 926697d31e..46bb23266a 100644 --- a/tests/unit/zai-web-chat-endpoint-8014-probe.test.ts +++ b/tests/unit/zai-web-chat-endpoint-8014-probe.test.ts @@ -9,7 +9,7 @@ const TEST_TOKEN = "e30.eyJpZCI6InVzZXItMTIzIn0.sig"; /** * #8014 guard: the executor must target the versioned v2 completions endpoint, * never the stale unversioned `/api/chat/completions` path, which 404s - * model-independently as of 2026-07. + * model-independently. * * Setup notes for this flow (the executor now creates a remote chat first and * signs the completion request): @@ -43,7 +43,7 @@ test("#8014: ZaiWebExecutor must POST to the current chat.z.ai v2 chat-completio try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "glm-4.6", + model: "glm-5.3", body: { messages: [{ role: "user", content: "hello" }] }, stream: false, credentials: { @@ -55,7 +55,6 @@ test("#8014: ZaiWebExecutor must POST to the current chat.z.ai v2 chat-completio assert.ok(requested.length > 0, "the direct path must actually reach fetch"); assert.ok( - // Exact-URL match (not a substring test): `requested` holds whole URLs. !requested.some((url) => url === STALE_URL), `zai-web executor POSTed to the stale endpoint — matches #8014's model-independent 404 "Not Found"` ); diff --git a/tests/unit/zai-web-models-discovery-7678.test.ts b/tests/unit/zai-web-models-discovery-7678.test.ts index 82837c541f..380c4ba504 100644 --- a/tests/unit/zai-web-models-discovery-7678.test.ts +++ b/tests/unit/zai-web-models-discovery-7678.test.ts @@ -13,7 +13,7 @@ const providersDb = await import("../../src/lib/db/providers.ts"); const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); const registry = await import("../../open-sse/config/providers/registry/zai-web/index.ts"); -const CURATED_ZAI_WEB_MODEL_IDS = ["glm-5.2", "GLM-5.1", "GLM-5-Turbo", "GLM-5v-Turbo"]; +const CURATED_ZAI_WEB_MODEL_IDS = ["glm-5.3-flash", "glm-5.3", "glm-5.2"]; async function resetStorage() { core.resetDbInstance(); @@ -31,34 +31,32 @@ test("zai-web publishes the live reasoning and vision capabilities", () => { registry.zai_webProvider.models.map((model) => ({ id: model.id, supportsReasoning: model.supportsReasoning === true, + supportedThinkingEfforts: model.supportedThinkingEfforts, supportsVision: model.supportsVision === true, toolCalling: model.toolCalling === true, })), [ + { + id: "glm-5.3-flash", + supportsReasoning: true, + supportedThinkingEfforts: ["low", "high", "max"], + supportsVision: true, + toolCalling: false, + }, + { + id: "glm-5.3", + supportsReasoning: true, + supportedThinkingEfforts: ["low", "high", "max"], + supportsVision: false, + toolCalling: false, + }, { id: "glm-5.2", supportsReasoning: true, + supportedThinkingEfforts: ["high", "max"], supportsVision: false, toolCalling: false, }, - { - id: "GLM-5.1", - supportsReasoning: true, - supportsVision: false, - toolCalling: false, - }, - { - id: "GLM-5-Turbo", - supportsReasoning: true, - supportsVision: false, - toolCalling: false, - }, - { - id: "GLM-5v-Turbo", - supportsReasoning: true, - supportsVision: true, - toolCalling: false, - }, ] ); }); From cdd07df700e2e1fea1fb0f866787b1d451bec762 Mon Sep 17 00:00:00 2001 From: backryun Date: Thu, 3 Sep 2026 19:50:02 +0900 Subject: [PATCH 10/47] feat(providers): refresh NVIDIA hosted models (#12538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com #12524, #12538, #12277 e #12367 sobre o tip de release/v3.8.51: os quatro boardaram sem conflito (áreas disjuntas — zai-web, nvidia, clova, cursor/devin/fable). typecheck:core limpo, check:provider-consistency OK (272 entradas REGISTRY, 355 providers canônicos), check:known-symbols OK, e 305/305 nos testes tocados pelos quatro PRs. Os IDs de modelo adicionados foram conferidos individualmente. Obrigado, @backryun. --- README.md | 8 +- docs/diagrams/free-tier-budget.svg | 4 +- open-sse/config/freeModelCatalog.data.ts | 11 +- .../config/nvidiaHostedModels.snapshot.json | 24 ++-- .../config/providers/registry/nvidia/index.ts | 127 +++--------------- src/lib/providers/nvidiaValidationModel.ts | 9 +- tests/integration/freeModelBenchmarkShared.ts | 4 +- tests/unit/catalog-updates-v3x.test.ts | 17 ++- tests/unit/clinepass-thinking-budget.test.ts | 4 +- tests/unit/free-models.test.ts | 24 ++-- .../combo-vision-provider-id-12112.test.ts | 20 ++- .../unit/model-capabilities-registry.test.ts | 2 +- tests/unit/nvidia-410-model-scope.test.ts | 2 +- tests/unit/nvidia-eol-catalog.test.ts | 40 ++---- .../nvidia-minimax-m3-removed-3329.test.ts | 14 +- .../nvidia-nim-catalog-expansion-2373.test.ts | 81 ----------- tests/unit/nvidia-nim-registry-6108.test.ts | 50 +++++-- tests/unit/nvidia-nim-validator.test.ts | 2 +- .../nvidia-passthrough-models-6773.test.ts | 14 +- .../unit/nvidia-validation-model-3116.test.ts | 15 ++- .../opencode-go-effort-aliases-8353.test.ts | 9 -- 21 files changed, 156 insertions(+), 325 deletions(-) delete mode 100644 tests/unit/nvidia-nim-catalog-expansion-2373.test.ts diff --git a/README.md b/README.md index 60d5827d99..a026f9c9c3 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ -> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **446 free-tier entries across 38 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`). +> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **437 free-tier entries across 38 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`). -OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from 38 documented recurring pool keys covering 446 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers. +OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from 38 documented recurring pool keys covering 437 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers. > Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**. > @@ -648,7 +648,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md) -> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **446 per-model rows**, **38 recurring pools** and **53 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md). +> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **437 per-model rows**, **38 recurring pools** and **53 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
@@ -1270,7 +1270,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi Resilience GuideCircuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing Auto-Combo Engine16-factor scoring, mode packs, self-healing Proxy Guide3-level proxy system, 1proxy marketplace, registry CRUD - Free TiersConsolidated directory: 38 documented recurring pools / 446 cataloged free-tier entries + Free TiersConsolidated directory: 38 documented recurring pools / 437 cataloged free-tier entries Features GalleryVisual dashboard tour with screenshots Codebase DocumentationBeginner-friendly codebase walkthrough diff --git a/docs/diagrams/free-tier-budget.svg b/docs/diagrams/free-tier-budget.svg index 51267b3ae2..72d1219cb2 100644 --- a/docs/diagrams/free-tier-budget.svg +++ b/docs/diagrams/free-tier-budget.svg @@ -1,4 +1,4 @@ - + Pool-deduplicated chart of the 20 recurring free-token pools with positive published budgets, plus signup credits and uncapped providers shown separately. @@ -64,7 +64,7 @@ ~1.51B FREE TOKENS / MONTH · STEADY up to ~2.13B in your first month — signup credits - documented free tiers · 38 recurring pools · 446 catalog entries · one endpoint + documented free tiers · 38 recurring pools · 437 catalog entries · one endpoint diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 2d82b69382..f107d7fef4 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -277,18 +277,9 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "nscale", modelId: "openai/gpt-oss-20b", displayName: "openai/gpt-oss-20b", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" }, { provider: "nscale", modelId: "meta-llama/Llama-4-Scout-17B-16E-Instruct", displayName: "meta-llama/Llama-4-Scout-17B-16E-Instruct", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" }, { provider: "nscale", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "meta-llama/Llama-3.3-70B-Instruct", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" }, - { provider: "nvidia", modelId: "z-ai/glm-5.2", displayName: "GLM 5.2", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "minimaxai/minimax-m2.7", displayName: "MiniMax M2.7", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "nvidia", modelId: "google/gemma-4-31b-it", displayName: "Gemma 4 31B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "mistralai/mistral-small-4-119b-2603", displayName: "Mistral Small 4 2603", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "mistralai/mistral-large-3-675b-instruct-2512", displayName: "Mistral Large 3 675B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "mistralai/devstral-2-123b-instruct-2512", displayName: "Devstral 2 123B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "qwen/qwen3.5-397b-a17b", displayName: "Qwen3.5-397B-A17B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "qwen/qwen3.5-122b-a10b", displayName: "Qwen3.5-122B-A10B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "stepfun-ai/step-3.5-flash", displayName: "Step 3.5 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "openai/gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "openai/gpt-oss-20b", displayName: "GPT OSS 20B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "nvidia", modelId: "nvidia/nemotron-3-super-120b-a12b", displayName: "Nemotron 3 Super 120B A12B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, + { provider: "nvidia", modelId: "openai/gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "ollama-cloud", modelId: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" }, { provider: "ollama-cloud", modelId: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" }, { provider: "ollama-cloud", modelId: "kimi-k2.6", displayName: "Kimi K2.6", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" }, diff --git a/open-sse/config/nvidiaHostedModels.snapshot.json b/open-sse/config/nvidiaHostedModels.snapshot.json index 60d2f2e76d..fa94a6a4a7 100644 --- a/open-sse/config/nvidiaHostedModels.snapshot.json +++ b/open-sse/config/nvidiaHostedModels.snapshot.json @@ -1,16 +1,14 @@ [ - "google/gemma-4-31b-it", - "minimaxai/minimax-m2.7", - "mistralai/devstral-2-123b-instruct-2512", - "mistralai/mistral-large-3-675b-instruct-2512", - "mistralai/mistral-small-4-119b-2603", - "nvidia/nemotron-3-super-120b-a12b", - "openai/gpt-oss-120b", - "openai/gpt-oss-20b", + "moonshotai/kimi-k3", + "deepseek-ai/deepseek-v4-pro-0813", + "deepseek-ai/deepseek-v4-flash-0731", + "meta/muse-glimmer-30b", "poolside/laguna-xs-2.1", - "qwen/qwen3.5-122b-a10b", - "qwen/qwen3.5-397b-a17b", - "stepfun-ai/step-3.5-flash", - "thinkingmachines/inkling", - "z-ai/glm-5.2" + "google/gemma-4-31b-it", + "google/diffusiongemma-26b-a4b-it", + "nvidia/nemotron-3-ultra-550b-a55b", + "nvidia/nemotron-3-super-120b-a12b", + "nvidia/nemotron-3.5-lightning-30b-a3b", + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + "openai/gpt-oss-120b" ] diff --git a/open-sse/config/providers/registry/nvidia/index.ts b/open-sse/config/providers/registry/nvidia/index.ts index 0966fa5941..dab45294d3 100644 --- a/open-sse/config/providers/registry/nvidia/index.ts +++ b/open-sse/config/providers/registry/nvidia/index.ts @@ -9,104 +9,34 @@ export const nvidiaProvider: RegistryEntry = { authType: "apikey", authHeader: "bearer", toolNameMaxLength: 64, - // #6773: nvidia multiplexes 17 models from 9 different upstream vendors - // (z-ai/, minimaxai/, deepseek-ai/, qwen/, mistralai/, stepfun-ai/, - // moonshotai/, openai/, nvidia/) behind ONE connection — mark it passthrough + // #6773: NVIDIA multiplexes models from multiple upstream vendors + // (moonshotai/, deepseek-ai/, nvidia/, meta/, poolside/, google/, openai/) + // behind ONE connection — mark it passthrough // so a single stale/renamed model's 404 locks out only that model instead // of cooling down the whole connection (see accountFallback.ts // hasPerModelQuota doc comment; matches modelscope/synthetic/kilo-gateway). passthroughModels: true, models: [ - // #6108: z-ai/glm-5.1 EOL'd 2026-07-02 (direct probe returns 410) — dropped. - // #10788: NVIDIA's hosted GLM-5.2 exposes a BINARY thinking switch - // (chat_template_kwargs.enable_thinking), not effort tiers — see - // mapNvidiaGlm52ReasoningParams. Declaring an empty tier list keeps the - // catalog from synthesizing unresolvable -low/-high/-max variant ids while - // still marking the model reasoning-capable. + { id: "moonshotai/kimi-k3", name: "Kimi K3" }, { - id: "z-ai/glm-5.2", - name: "GLM 5.2", + id: "deepseek-ai/deepseek-v4-pro-0813", + name: "DeepSeek V4 Pro 0813", supportsReasoning: true, - supportedThinkingEfforts: [], }, - // #3329/#6108: minimaxai/minimax-m3 stays excluded from the nvidia tier — it - // still 404s here for most callers; the single 200 probe in #6108 was not - // reproducible enough to override the #3329 guard. Re-add only once NVIDIA - // reliably serves it (and flip nvidia-minimax-m3-removed-3329.test.ts then). - { id: "minimaxai/minimax-m2.7", name: "MiniMax M2.7" }, + { + id: "deepseek-ai/deepseek-v4-flash-0731", + name: "DeepSeek V4 Flash 0731", + supportsReasoning: true, + }, + { id: "meta/muse-glimmer-30b", name: "Muse Glimmer 30B" }, + { id: "poolside/laguna-xs-2.1", name: "Laguna XS 2.1" }, { id: "google/gemma-4-31b-it", name: "Gemma 4 31B" }, - { id: "mistralai/mistral-small-4-119b-2603", name: "Mistral Small 4 2603" }, - { id: "mistralai/mistral-large-3-675b-instruct-2512", name: "Mistral Large 3 675B" }, - { id: "mistralai/devstral-2-123b-instruct-2512", name: "Devstral 2 123B" }, - { id: "qwen/qwen3.5-397b-a17b", name: "Qwen3.5-397B-A17B" }, - { id: "qwen/qwen3.5-122b-a10b", name: "Qwen3.5-122B-A10B" }, - { id: "stepfun-ai/step-3.5-flash", name: "Step 3.5 Flash" }, - { id: "stepfun-ai/step-3.7-flash", name: "Step 3.7 Flash" }, - // Sweep 2026-06-19: verified present in the live NVIDIA NIM /v1/models catalog. - { id: "moonshotai/kimi-k2.6", name: "Kimi K2.6" }, - { id: "openai/gpt-oss-120b", name: "GPT OSS 120B", toolCalling: false }, - { id: "openai/gpt-oss-20b", name: "GPT OSS 20B", toolCalling: false }, + { id: "google/diffusiongemma-26b-a4b-it", name: "DiffusionGemma 26B A4B IT" }, + { id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra 550B A55B" }, { id: "nvidia/nemotron-3-super-120b-a12b", name: "Nemotron 3 Super 120B A12B" }, - { id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra 550B" }, - // Port of decolua/9router#2373 ("fix(nvidia): expand NIM chat model catalog"): - // additional live-catalog models observed to serve /v1/chat/completions. - // `minimaxai/minimax-m3` from that PR is intentionally NOT re-added — it stays - // excluded per the #3329 guard (nvidia-minimax-m3-removed-3329.test.ts). - // Non-chat entries from the same PR (nvidia/gliner-pii — NER tagger, not a chat - // model; google/diffusiongemma-26b-a4b-it — diffusion model) are dropped for the - // same reason: this registry only models the /v1/chat/completions surface. - { id: "abacusai/dracarys-llama-3.1-70b-instruct", name: "Dracarys Llama 3.1 70B Instruct" }, - { id: "google/gemma-2-2b-it", name: "Gemma 2 2B IT" }, - { id: "google/gemma-3n-e2b-it", name: "Gemma 3n E2B IT" }, - { id: "meta/llama-3.1-8b-instruct", name: "Llama 3.1 8B Instruct", toolCalling: false }, { - id: "meta/llama-3.2-11b-vision-instruct", - name: "Llama 3.2 11B Vision Instruct", - supportsVision: true, - }, - { id: "meta/llama-3.2-1b-instruct", name: "Llama 3.2 1B Instruct" }, - { id: "meta/llama-3.2-3b-instruct", name: "Llama 3.2 3B Instruct", toolCalling: false }, - { - id: "meta/llama-3.2-90b-vision-instruct", - name: "Llama 3.2 90B Vision Instruct", - supportsVision: true, - }, - { id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick 17B 128E Instruct" }, - { id: "meta/llama-guard-4-12b", name: "Llama Guard 4 12B", toolCalling: false }, - { id: "mistralai/ministral-14b-instruct-2512", name: "Ministral 14B Instruct 2512" }, - { id: "mistralai/mistral-medium-3.5-128b", name: "Mistral Medium 3.5 128B" }, - { id: "mistralai/mistral-nemotron", name: "Mistral Nemotron" }, - { id: "mistralai/mixtral-8x7b-instruct-v0.1", name: "Mixtral 8x7B Instruct v0.1" }, - { - id: "nvidia/ising-calibration-1-35b-a3b", - name: "Ising Calibration 1 35B A3B", - supportsReasoning: true, - }, - { - id: "nvidia/llama-3.1-nemoguard-8b-content-safety", - name: "Llama 3.1 Nemoguard 8B Content Safety", - }, - { - id: "nvidia/llama-3.1-nemoguard-8b-topic-control", - name: "Llama 3.1 Nemoguard 8B Topic Control", - }, - { id: "nvidia/llama-3.1-nemotron-nano-8b-v1", name: "Llama 3.1 Nemotron Nano 8B v1" }, - { - id: "nvidia/llama-3.1-nemotron-nano-vl-8b-v1", - name: "Llama 3.1 Nemotron Nano VL 8B v1", - supportsVision: true, - }, - { - id: "nvidia/llama-3.1-nemotron-safety-guard-8b-v3", - name: "Llama 3.1 Nemotron Safety Guard 8B v3", - }, - { id: "nvidia/llama-3.3-nemotron-super-49b-v1", name: "Llama 3.3 Nemotron Super 49B v1" }, - { id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", name: "Llama 3.3 Nemotron Super 49B v1.5" }, - { id: "nvidia/nemotron-3-content-safety", name: "Nemotron 3 Content Safety" }, - { - id: "nvidia/nemotron-3-nano-30b-a3b", - name: "Nemotron 3 Nano 30B A3B", - supportsReasoning: true, + id: "nvidia/nemotron-3.5-lightning-30b-a3b", + name: "Nemotron 3.5 Lightning 30B A3B", }, { id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", @@ -114,27 +44,6 @@ export const nvidiaProvider: RegistryEntry = { supportsReasoning: true, supportsVision: true, }, - { id: "nvidia/nemotron-3.5-content-safety", name: "Nemotron 3.5 Content Safety" }, - { id: "nvidia/nemotron-mini-4b-instruct", name: "Nemotron Mini 4B Instruct" }, - { - id: "nvidia/nemotron-nano-12b-v2-vl", - name: "Nemotron Nano 12B v2 VL", - supportsReasoning: true, - supportsVision: true, - }, - { - id: "nvidia/nvidia-nemotron-nano-9b-v2", - name: "NVIDIA Nemotron Nano 9B v2", - supportsReasoning: true, - }, - { id: "nvidia/riva-translate-4b-instruct-v1.1", name: "Riva Translate 4B Instruct v1.1" }, - { - id: "qwen/qwen3-next-80b-a3b-instruct", - name: "Qwen3 Next 80B A3B Instruct", - supportsReasoning: true, - }, - { id: "sarvamai/sarvam-m", name: "Sarvam M" }, - { id: "stockmark/stockmark-2-100b-instruct", name: "Stockmark 2 100B Instruct" }, - { id: "upstage/solar-10.7b-instruct", name: "Solar 10.7B Instruct" }, + { id: "openai/gpt-oss-120b", name: "GPT OSS 120B", toolCalling: false }, ], }; diff --git a/src/lib/providers/nvidiaValidationModel.ts b/src/lib/providers/nvidiaValidationModel.ts index bde123944b..07ef6291b4 100644 --- a/src/lib/providers/nvidiaValidationModel.ts +++ b/src/lib/providers/nvidiaValidationModel.ts @@ -9,11 +9,12 @@ * probe HANG until the validation timeout, which surfaces as a misleading "Upstream * Error" on an otherwise-valid key. * - * `meta/llama-3.1-8b-instruct` is a long-lived, universally-available NIM model (no - * special permission), so it is a far more reliable auth probe. A connection may still - * override it via `providerSpecificData.validationModelId`. + * The default must stay inside the current NVIDIA hosted-model catalog. Nemotron 3.5 + * Lightning is the smallest retained general chat model, which keeps the auth probe + * lightweight. A connection may still override it via + * `providerSpecificData.validationModelId`. */ -export const NVIDIA_DEFAULT_VALIDATION_MODEL = "meta/llama-3.1-8b-instruct"; +export const NVIDIA_DEFAULT_VALIDATION_MODEL = "nvidia/nemotron-3.5-lightning-30b-a3b"; export function resolveNvidiaValidationModel(providerSpecificData?: { validationModelId?: unknown; diff --git a/tests/integration/freeModelBenchmarkShared.ts b/tests/integration/freeModelBenchmarkShared.ts index caf5cd8e78..e732c6e0b6 100644 --- a/tests/integration/freeModelBenchmarkShared.ts +++ b/tests/integration/freeModelBenchmarkShared.ts @@ -56,8 +56,8 @@ export const FREE_MODELS: FreeModelSpec[] = [ displayName: "Gemini 3.1 Flash-Lite", }, { provider: "gemini", model: "gemini/gemma-4-31b-it", displayName: "Gemma 4 31B (Gemini)" }, - { provider: "nvidia", model: "nvidia/openai/gpt-oss-20b", displayName: "GPT OSS 20B (NVIDIA)" }, - { provider: "nvidia", model: "nvidia/z-ai/glm-5.1", displayName: "GLM 5.1 (NVIDIA)" }, + { provider: "nvidia", model: "nvidia/openai/gpt-oss-120b", displayName: "GPT OSS 120B (NVIDIA)" }, + { provider: "nvidia", model: "nvidia/moonshotai/kimi-k3", displayName: "Kimi K3 (NVIDIA)" }, { provider: "nvidia", model: "nvidia/google/gemma-4-31b-it", diff --git a/tests/unit/catalog-updates-v3x.test.ts b/tests/unit/catalog-updates-v3x.test.ts index e95132bec6..e233380490 100644 --- a/tests/unit/catalog-updates-v3x.test.ts +++ b/tests/unit/catalog-updates-v3x.test.ts @@ -23,18 +23,21 @@ test("Pollinations catalog mirrors the current public text model lineup", () => ); }); -test("NVIDIA catalog includes the verified 2026 additions and GPT OSS 20B alias resolution", () => { +test("NVIDIA catalog includes the current hosted models and GPT OSS 120B alias resolution", () => { const ids = new Set(getModelsByProviderId("nvidia").map((model) => model.id)); - assert.ok(ids.has("openai/gpt-oss-20b")); + assert.ok(ids.has("moonshotai/kimi-k3")); + assert.ok(ids.has("deepseek-ai/deepseek-v4-pro-0813")); + assert.ok(ids.has("deepseek-ai/deepseek-v4-flash-0731")); + assert.ok(ids.has("nvidia/nemotron-3.5-lightning-30b-a3b")); + assert.ok(ids.has("meta/muse-glimmer-30b")); + assert.ok(ids.has("google/diffusiongemma-26b-a4b-it")); + assert.ok(ids.has("openai/gpt-oss-120b")); assert.ok(ids.has("nvidia/nemotron-3-super-120b-a12b")); - assert.ok(ids.has("mistralai/mistral-large-3-675b-instruct-2512")); - assert.ok(ids.has("qwen/qwen3.5-397b-a17b")); - assert.ok(ids.has("mistralai/devstral-2-123b-instruct-2512")); - assert.deepEqual(resolveCanonicalProviderModel("nvidia", "gpt-oss-20b"), { + assert.deepEqual(resolveCanonicalProviderModel("nvidia", "gpt-oss-120b"), { provider: "nvidia", - model: "openai/gpt-oss-20b", + model: "openai/gpt-oss-120b", }); }); diff --git a/tests/unit/clinepass-thinking-budget.test.ts b/tests/unit/clinepass-thinking-budget.test.ts index b1452aedaa..7883294d0d 100644 --- a/tests/unit/clinepass-thinking-budget.test.ts +++ b/tests/unit/clinepass-thinking-budget.test.ts @@ -83,11 +83,11 @@ test("bumps undersized max_tokens for a non-clinepass reasoning provider (gate r // Nemotron Nano with supportsReasoning in the NVIDIA registry. const executor = new DefaultExecutor("nvidia"); const body = { - model: "nvidia/nvidia-nemotron-nano-9b-v2", + model: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", reasoning_effort: "high", max_tokens: 100, } as Record; - executor.ensureThinkingBudget(body, "nvidia/nvidia-nemotron-nano-9b-v2"); + executor.ensureThinkingBudget(body, "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning"); assert.equal(body.max_tokens, 4096); }); diff --git a/tests/unit/free-models.test.ts b/tests/unit/free-models.test.ts index e857db1bdf..0d4ae6a566 100644 --- a/tests/unit/free-models.test.ts +++ b/tests/unit/free-models.test.ts @@ -57,15 +57,12 @@ test("isFreeModel: a model id listed in the free catalog for that provider is fr assert.equal(isFreeModel(sample.provider, { id: sample.modelId }), true); }); -test("isFreeModel: NVIDIA GLM 5.2 is included in the reviewed trial catalog", () => { - assert.equal(isFreeModel("nvidia", { id: "z-ai/glm-5.2" }), true); +test("isFreeModel: NVIDIA GPT OSS 120B remains in the reviewed trial catalog", () => { + assert.equal(isFreeModel("nvidia", { id: "openai/gpt-oss-120b" }), true); }); test("selectModelsForImport: passthrough when importFreeOnly is false", () => { - const models = [ - { id: "a:free" }, - { id: "b", pricing: { prompt: "0.01", completion: "0.02" } }, - ]; + const models = [{ id: "a:free" }, { id: "b", pricing: { prompt: "0.01", completion: "0.02" } }]; const result = selectModelsForImport("openrouter", models, false); assert.equal(result.models.length, 2); assert.equal(result.freeFilterEmpty, false); @@ -130,8 +127,14 @@ test("sortModelsFreeFirst: deterministic (alphabetical) within each group, regar ], { isFree: (m) => m.isFree, key: (m) => m.id } ); - assert.deepEqual(a.map((m) => m.id), ["a", "b", "c"]); - assert.deepEqual(b.map((m) => m.id), ["a", "b", "c"]); + assert.deepEqual( + a.map((m) => m.id), + ["a", "b", "c"] + ); + assert.deepEqual( + b.map((m) => m.id), + ["a", "b", "c"] + ); }); test("sortModelsFreeFirst: does not mutate the input array", () => { @@ -141,5 +144,8 @@ test("sortModelsFreeFirst: does not mutate the input array", () => { ]; const before = items.map((m) => m.id); sortModelsFreeFirst(items, { isFree: (m) => m.isFree, key: (m) => m.id }); - assert.deepEqual(items.map((m) => m.id), before); + assert.deepEqual( + items.map((m) => m.id), + before + ); }); diff --git a/tests/unit/guardrails/combo-vision-provider-id-12112.test.ts b/tests/unit/guardrails/combo-vision-provider-id-12112.test.ts index 734e13e2fe..c4ddaf6fe9 100644 --- a/tests/unit/guardrails/combo-vision-provider-id-12112.test.ts +++ b/tests/unit/guardrails/combo-vision-provider-id-12112.test.ts @@ -3,29 +3,27 @@ import assert from "node:assert/strict"; process.env.DATA_DIR = `/tmp/omniroute-test-12112-${Date.now()}`; -const { getComboVisionBridgeDecision } = await import( - "../../../src/lib/guardrails/visionBridge.ts" -); +const { getComboVisionBridgeDecision } = + await import("../../../src/lib/guardrails/visionBridge.ts"); const combosDb = await import("../../../src/lib/db/combos.ts"); const core = await import("../../../src/lib/db/core.ts"); -const { isVisionIncompatibleTarget } = await import( - "../../../open-sse/services/combo/comboStructure.ts" -); +const { isVisionIncompatibleTarget } = + await import("../../../open-sse/services/combo/comboStructure.ts"); import type { ResolvedComboTarget } from "../../../open-sse/services/combo/types.ts"; test.after(() => { core.resetDbInstance(); }); -test("#12112: checkComboVision respects providerId for namespaced vision models (e.g. nvidia/nemotron-nano-12b-v2-vl)", async () => { - // Model 'nvidia/nemotron-nano-12b-v2-vl' is declared with supportsVision: true in nvidia provider registry. +test("#12112: checkComboVision respects providerId for namespaced vision models (e.g. nvidia/nemotron-3-nano-omni-30b-a3b-reasoning)", async () => { + // Model 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning' is declared with supportsVision: true in nvidia provider registry. // It has a slash in model id and requires providerId="nvidia" to resolve capabilities. await combosDb.createCombo({ name: "nvidia-vision-combo-12112", models: [ { providerId: "nvidia", - model: "nvidia/nemotron-nano-12b-v2-vl", + model: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", weight: 1, }, ], @@ -44,7 +42,7 @@ test("#12112: isVisionIncompatibleTarget passes providerId to resolve vision cap kind: "model", stepId: "step-1", executionKey: "step-1", - modelStr: "nvidia/nemotron-nano-12b-v2-vl", + modelStr: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", provider: "nvidia", providerId: "nvidia", connectionId: "conn-1", @@ -64,6 +62,6 @@ test("#12112: isVisionIncompatibleTarget passes providerId to resolve vision cap assert.equal( incompatible, false, - "Target with providerId='nvidia' and model='nvidia/nemotron-nano-12b-v2-vl' must be vision-compatible" + "Target with providerId='nvidia' and model='nvidia/nemotron-3-nano-omni-30b-a3b-reasoning' must be vision-compatible" ); }); diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index 7bb82681db..98adbbf21a 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -203,7 +203,7 @@ test("GPT OSS and DeepSeek Reasoner models support tool calling", () => { // GPT OSS models should not be blocked by the heuristic assert.equal(modelCapabilities.supportsToolCalling("fake-provider/gpt-oss-120b"), true); assert.equal(modelCapabilities.supportsToolCalling("gpt-oss-120b"), true); - assert.equal(modelCapabilities.supportsToolCalling("nvidia/openai/gpt-oss-20b"), false); // in registry + assert.equal(modelCapabilities.supportsToolCalling("nvidia/openai/gpt-oss-120b"), false); // in registry // DeepSeek Reasoner supports tool calling assert.equal(modelCapabilities.supportsToolCalling("deepseek-reasoner"), true); diff --git a/tests/unit/nvidia-410-model-scope.test.ts b/tests/unit/nvidia-410-model-scope.test.ts index 0d72fe1345..9865e1f5f8 100644 --- a/tests/unit/nvidia-410-model-scope.test.ts +++ b/tests/unit/nvidia-410-model-scope.test.ts @@ -15,7 +15,7 @@ const auth = await import("../../src/sse/services/auth.ts"); const fallback = await import("../../open-sse/services/accountFallback.ts"); const DEAD_MODEL = "deepseek-ai/deepseek-v4-pro"; -const HEALTHY_MODEL = "z-ai/glm-5.2"; +const HEALTHY_MODEL = "nvidia/nemotron-3.5-lightning-30b-a3b"; const GONE_BODY = JSON.stringify({ type: "about:blank", diff --git a/tests/unit/nvidia-eol-catalog.test.ts b/tests/unit/nvidia-eol-catalog.test.ts index ea8708d19e..f5319d08e0 100644 --- a/tests/unit/nvidia-eol-catalog.test.ts +++ b/tests/unit/nvidia-eol-catalog.test.ts @@ -13,20 +13,15 @@ const documentedFreeIds = new Set( const reviewedIds = new Set(reviewedLiveIds); -test("NVIDIA registry excludes retired DeepSeek V4 models", () => { - assert.ok( - !registryIds.has("deepseek-ai/deepseek-v4-pro"), - "retired deepseek-ai/deepseek-v4-pro must not be advertised" - ); - - assert.ok( - !registryIds.has("deepseek-ai/deepseek-v4-flash"), - "retired deepseek-ai/deepseek-v4-flash must not be advertised" - ); -}); - -test("NVIDIA static lifecycle metadata excludes known EOL models", () => { - for (const modelId of ["z-ai/glm-5.1", "deepseek-ai/deepseek-v4-pro"]) { +test("NVIDIA static catalog metadata excludes superseded model ids", () => { + for (const modelId of [ + "z-ai/glm-5.1", + "z-ai/glm-5.2", + "deepseek-ai/deepseek-v4-pro", + "deepseek-ai/deepseek-v4-flash", + "minimaxai/minimax-m2.7", + ]) { + assert.ok(!registryIds.has(modelId), `${modelId} must not remain in the NVIDIA registry`); assert.ok( !reviewedIds.has(modelId), `${modelId} must not remain in the reviewed NVIDIA hosted-model snapshot` @@ -39,16 +34,9 @@ test("NVIDIA static lifecycle metadata excludes known EOL models", () => { } }); -test("NVIDIA cleanup preserves the healthy GLM replacement", () => { - assert.ok(registryIds.has("z-ai/glm-5.2"), "z-ai/glm-5.2 must remain in the NVIDIA registry"); - - assert.ok( - reviewedIds.has("z-ai/glm-5.2"), - "z-ai/glm-5.2 must remain in the reviewed NVIDIA hosted-model snapshot" - ); - - assert.ok( - documentedFreeIds.has("z-ai/glm-5.2"), - "z-ai/glm-5.2 must remain in the NVIDIA free-model catalog" - ); +test("NVIDIA reviewed snapshot matches the registry and trial entries remain valid", () => { + assert.deepEqual([...reviewedIds], [...registryIds]); + for (const modelId of documentedFreeIds) { + assert.ok(registryIds.has(modelId), `${modelId} must exist in the NVIDIA hosted catalog`); + } }); diff --git a/tests/unit/nvidia-minimax-m3-removed-3329.test.ts b/tests/unit/nvidia-minimax-m3-removed-3329.test.ts index 77e0ce4822..f0c8b6d502 100644 --- a/tests/unit/nvidia-minimax-m3-removed-3329.test.ts +++ b/tests/unit/nvidia-minimax-m3-removed-3329.test.ts @@ -4,16 +4,16 @@ import assert from "node:assert/strict"; const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts"); // #3329: `minimaxai/minimax-m3` was registered in the nvidia (NVIDIA NIM) tier, -// but NVIDIA NIM does not host it — every request returns `404 page not found`, -// while sibling models on the same provider (e.g. `minimaxai/minimax-m2.7`) -// work. Advertising a model that 404s is a catalog bug; it is removed from the -// nvidia tier until NVIDIA actually serves it. It remains on the tiers that do -// (minimax / minimax-cn / opencode / etc.). +// but NVIDIA NIM does not host it — every request returns `404 page not found`. +// Advertising a model that 404s is a catalog bug; it stays absent from the +// NVIDIA tier while remaining available from providers that actually serve it. test("nvidia tier does not advertise minimaxai/minimax-m3 (404 upstream) (#3329)", () => { const nvidia = getRegistryEntry("nvidia"); assert.ok(nvidia, "nvidia registry entry must exist"); const ids = (nvidia.models ?? []).map((m) => m.id); assert.ok(!ids.includes("minimaxai/minimax-m3"), "minimaxai/minimax-m3 must not be in nvidia"); - // sanity: the working sibling stays listed - assert.ok(ids.includes("minimaxai/minimax-m2.7"), "minimaxai/minimax-m2.7 stays available"); + assert.ok( + !ids.includes("minimaxai/minimax-m2.7"), + "removed minimaxai/minimax-m2.7 must stay out" + ); }); diff --git a/tests/unit/nvidia-nim-catalog-expansion-2373.test.ts b/tests/unit/nvidia-nim-catalog-expansion-2373.test.ts deleted file mode 100644 index 32d14a8a00..0000000000 --- a/tests/unit/nvidia-nim-catalog-expansion-2373.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { nvidiaProvider } from "../../open-sse/config/providers/registry/nvidia/index.ts"; - -// Port of decolua/9router#2373 ("fix(nvidia): expand NIM chat model catalog"). Upstream's -// PR also added a per-model `thinkingFormat`/`kind` capability shape in a legacy -// open-sse/providers/capabilities.js file that has no equivalent in OmniRoute — reasoning -// translation here is per-PROVIDER (open-sse/translator/paramSupport.ts, -// executors/default.ts, both gated on `this.provider === "nvidia"`), not per-model, so -// only the catalog (RegistryModel.supportsReasoning/supportsVision) needed porting. -// Embedding/ASR/TTS entries from the same upstream PR are already covered by -// open-sse/config/embeddingRegistry.ts and audioRegistry.ts, so they are not duplicated -// here. `minimaxai/minimax-m3` is intentionally excluded — see the #3329 guard -// (nvidia-minimax-m3-removed-3329.test.ts). -const modelIds = new Set(nvidiaProvider.models.map((m) => m.id)); - -test("#2373: NVIDIA NIM registry gains the newly-observed chat-completions models", () => { - for (const id of [ - "abacusai/dracarys-llama-3.1-70b-instruct", - "google/gemma-2-2b-it", - "google/gemma-3n-e2b-it", - "meta/llama-3.1-8b-instruct", - "meta/llama-3.2-11b-vision-instruct", - "meta/llama-4-maverick-17b-128e-instruct", - "meta/llama-guard-4-12b", - "mistralai/ministral-14b-instruct-2512", - "mistralai/mistral-medium-3.5-128b", - "mistralai/mistral-nemotron", - "mistralai/mixtral-8x7b-instruct-v0.1", - "nvidia/ising-calibration-1-35b-a3b", - "nvidia/llama-3.1-nemoguard-8b-content-safety", - "nvidia/llama-3.3-nemotron-super-49b-v1.5", - "nvidia/nemotron-3-nano-30b-a3b", - "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", - "nvidia/nemotron-nano-12b-v2-vl", - "nvidia/nvidia-nemotron-nano-9b-v2", - "qwen/qwen3-next-80b-a3b-instruct", - "sarvamai/sarvam-m", - "stockmark/stockmark-2-100b-instruct", - "upstage/solar-10.7b-instruct", - ]) { - assert.ok(modelIds.has(id), `expected nvidia registry to include ${id}`); - } -}); - -test("#2373: reasoning-capable NVIDIA-hosted models are flagged supportsReasoning", () => { - const reasoningIds = [ - "nvidia/ising-calibration-1-35b-a3b", - "nvidia/nemotron-3-nano-30b-a3b", - "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", - "nvidia/nemotron-nano-12b-v2-vl", - "nvidia/nvidia-nemotron-nano-9b-v2", - "qwen/qwen3-next-80b-a3b-instruct", - ]; - for (const id of reasoningIds) { - const model = nvidiaProvider.models.find((m) => m.id === id); - assert.ok(model, `model ${id} must exist`); - assert.equal(model?.supportsReasoning, true, `${id} must be supportsReasoning: true`); - } -}); - -test("#2373/#3329: minimaxai/minimax-m3 stays excluded from the nvidia tier", () => { - assert.ok( - !modelIds.has("minimaxai/minimax-m3"), - "minimaxai/minimax-m3 must not be re-added to the nvidia registry (404 upstream, #3329)" - ); - // sanity: the working sibling stays listed - assert.ok(modelIds.has("minimaxai/minimax-m2.7"), "minimaxai/minimax-m2.7 stays available"); -}); - -test("#2373: non-chat model kinds (NER/diffusion) from the upstream PR are not ported into the chat registry", () => { - assert.ok( - !modelIds.has("nvidia/gliner-pii"), - "nvidia/gliner-pii is an NER/PII tagger, not a chat-completions model" - ); - assert.ok( - !modelIds.has("google/diffusiongemma-26b-a4b-it"), - "google/diffusiongemma-26b-a4b-it is a diffusion model, not a chat-completions model" - ); -}); diff --git a/tests/unit/nvidia-nim-registry-6108.test.ts b/tests/unit/nvidia-nim-registry-6108.test.ts index b9c12d9992..702747656d 100644 --- a/tests/unit/nvidia-nim-registry-6108.test.ts +++ b/tests/unit/nvidia-nim-registry-6108.test.ts @@ -3,21 +3,45 @@ import assert from "node:assert/strict"; import { nvidiaProvider } from "../../open-sse/config/providers/registry/nvidia/index.ts"; -// Regression guard for #6108: the static NVIDIA NIM model registry had gone -// stale — z-ai/glm-5.1 was EOL'd (410) 2026-07-02, while glm-5.2 and -// nvidia/nemotron-3-ultra-550b-a55b were absent. minimaxai/minimax-m3 stays -// excluded per the #3329 guard (nvidia-minimax-m3-removed-3329.test.ts) — the -// single 200 probe in #6108 wasn't reproducible enough to override it. -const modelIds = new Set(nvidiaProvider.models.map((m) => m.id)); +const EXPECTED_MODEL_IDS = [ + "moonshotai/kimi-k3", + "deepseek-ai/deepseek-v4-pro-0813", + "deepseek-ai/deepseek-v4-flash-0731", + "meta/muse-glimmer-30b", + "poolside/laguna-xs-2.1", + "google/gemma-4-31b-it", + "google/diffusiongemma-26b-a4b-it", + "nvidia/nemotron-3-ultra-550b-a55b", + "nvidia/nemotron-3-super-120b-a12b", + "nvidia/nemotron-3.5-lightning-30b-a3b", + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + "openai/gpt-oss-120b", +] as const; -test("#6108: NVIDIA NIM registry contains the refreshed live models", () => { - assert.ok(modelIds.has("z-ai/glm-5.2"), "z-ai/glm-5.2 must be present"); - assert.ok( - modelIds.has("nvidia/nemotron-3-ultra-550b-a55b"), - "nvidia/nemotron-3-ultra-550b-a55b must be present" +test("NVIDIA NIM registry exactly matches the current hosted-model catalog", () => { + assert.deepEqual( + nvidiaProvider.models.map((model) => model.id), + EXPECTED_MODEL_IDS ); }); -test("#6108: NVIDIA NIM registry no longer lists EOL z-ai/glm-5.1", () => { - assert.ok(!modelIds.has("z-ai/glm-5.1"), "EOL z-ai/glm-5.1 must be removed"); +test("NVIDIA NIM registry preserves known model capabilities", () => { + const byId = new Map(nvidiaProvider.models.map((model) => [model.id, model])); + + for (const id of [ + "deepseek-ai/deepseek-v4-pro-0813", + "deepseek-ai/deepseek-v4-flash-0731", + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + ]) { + assert.equal(byId.get(id)?.supportsReasoning, true, `${id} must support reasoning`); + } + + const omni = byId.get("nvidia/nemotron-3-nano-omni-30b-a3b-reasoning"); + assert.equal(omni?.supportsVision, true, "Nemotron 3 Nano Omni must support vision"); + + assert.equal( + byId.get("openai/gpt-oss-120b")?.toolCalling, + false, + "openai/gpt-oss-120b must keep tool calling disabled" + ); }); diff --git a/tests/unit/nvidia-nim-validator.test.ts b/tests/unit/nvidia-nim-validator.test.ts index f6f82e44f6..b6c2c5518d 100644 --- a/tests/unit/nvidia-nim-validator.test.ts +++ b/tests/unit/nvidia-nim-validator.test.ts @@ -135,7 +135,7 @@ test("nvidia specialty validator falls back to stable chat validation model", as calls.some((u) => u.endsWith("/chat/completions")), `should fall back to /chat/completions, called: ${JSON.stringify(calls)}` ); - assert.equal(payload?.model, "meta/llama-3.1-8b-instruct"); + assert.equal(payload?.model, "nvidia/nemotron-3.5-lightning-30b-a3b"); } ); }); diff --git a/tests/unit/nvidia-passthrough-models-6773.test.ts b/tests/unit/nvidia-passthrough-models-6773.test.ts index 915b541413..9275367845 100644 --- a/tests/unit/nvidia-passthrough-models-6773.test.ts +++ b/tests/unit/nvidia-passthrough-models-6773.test.ts @@ -2,8 +2,8 @@ * Regression test for #6773 — NVIDIA NIM models listed available:true but 404 at router. * * Root cause: the `nvidia` provider registry entry multiplexes many distinct - * third-party vendor models (z-ai/, minimaxai/, deepseek-ai/, qwen/, - * mistralai/, stepfun-ai/, moonshotai/, openai/, nvidia/) behind ONE base URL + * third-party vendor models (moonshotai/, deepseek-ai/, nvidia/, meta/, + * poolside/, google/, openai/) behind ONE base URL * and ONE API key connection — architecturally identical to `modelscope`, * `synthetic`, and `kilo-gateway`, which all set `passthroughModels: true` so * that a single model's 404/429 stays scoped to that model instead of cooling @@ -24,8 +24,8 @@ test("#6773: nvidia registry entry sets passthroughModels", () => { entry?.passthroughModels, true, "nvidia multiplexes many third-party vendor models behind one connection " + - "(z-ai/, minimaxai/, deepseek-ai/, qwen/, mistralai/, stepfun-ai/, " + - "moonshotai/, openai/, nvidia/) — it should set passthroughModels: true " + + "(moonshotai/, deepseek-ai/, nvidia/, meta/, poolside/, google/, " + + "openai/) — it should set passthroughModels: true " + "like modelscope/synthetic/kilo-gateway, so a single stale model 404 " + "does not cool down the whole connection for all other models" ); @@ -33,7 +33,7 @@ test("#6773: nvidia registry entry sets passthroughModels", () => { test("#6773: hasPerModelQuota('nvidia') is true, so a 404 on one nvidia model is model-scoped", () => { assert.equal( - accountFallback.hasPerModelQuota("nvidia", "z-ai/glm-5.2"), + accountFallback.hasPerModelQuota("nvidia", "nvidia/nemotron-3.5-lightning-30b-a3b"), true, "expected nvidia to use per-model lockout (like gemini/github/codex/compatible " + "providers) so a 404 on one model doesn't cool down the other nvidia models" @@ -50,7 +50,7 @@ test("#6773: checkFallbackError + lockModelIfPerModelQuota scope a single-model 404, "Not Found", 0, - "z-ai/glm-5.2", + "nvidia/nemotron-3.5-lightning-30b-a3b", "nvidia", null, null, @@ -66,7 +66,7 @@ test("#6773: checkFallbackError + lockModelIfPerModelQuota scope a single-model const locked = accountFallback.lockModelIfPerModelQuota( "nvidia", "conn-6773", - "z-ai/glm-5.2", + "nvidia/nemotron-3.5-lightning-30b-a3b", "unknown", result.cooldownMs ?? 30_000 ); diff --git a/tests/unit/nvidia-validation-model-3116.test.ts b/tests/unit/nvidia-validation-model-3116.test.ts index 1d52f68059..da7e541fd2 100644 --- a/tests/unit/nvidia-validation-model-3116.test.ts +++ b/tests/unit/nvidia-validation-model-3116.test.ts @@ -2,7 +2,7 @@ * #3116 — NVIDIA key validation probed the first catalog model (`z-ai/glm-5.1`), which * requires the "Public API Endpoints" account permission and can hang/be DEGRADED, * making a *valid* key fail with a misleading "Upstream Error". The probe now defaults to - * the universally-available `meta/llama-3.1-8b-instruct`, with a per-connection override. + * a lightweight model from the current hosted catalog, with a per-connection override. */ import test from "node:test"; import assert from "node:assert/strict"; @@ -12,10 +12,10 @@ import { resolveNvidiaValidationModel, } from "../../src/lib/providers/nvidiaValidationModel.ts"; -test("defaults to a stable, permission-free NVIDIA model (not the gated glm-5.1)", () => { - assert.equal(NVIDIA_DEFAULT_VALIDATION_MODEL, "meta/llama-3.1-8b-instruct"); - assert.equal(resolveNvidiaValidationModel(), "meta/llama-3.1-8b-instruct"); - assert.equal(resolveNvidiaValidationModel({}), "meta/llama-3.1-8b-instruct"); +test("defaults to a lightweight model in the current NVIDIA catalog", () => { + assert.equal(NVIDIA_DEFAULT_VALIDATION_MODEL, "nvidia/nemotron-3.5-lightning-30b-a3b"); + assert.equal(resolveNvidiaValidationModel(), "nvidia/nemotron-3.5-lightning-30b-a3b"); + assert.equal(resolveNvidiaValidationModel({}), "nvidia/nemotron-3.5-lightning-30b-a3b"); assert.notEqual(resolveNvidiaValidationModel(undefined), "z-ai/glm-5.1"); }); @@ -25,5 +25,8 @@ test("honors a per-connection validationModelId override", () => { "nvidia/llama-3.3-nemotron-super-49b" ); // blank/whitespace override falls back to the default - assert.equal(resolveNvidiaValidationModel({ validationModelId: " " }), NVIDIA_DEFAULT_VALIDATION_MODEL); + assert.equal( + resolveNvidiaValidationModel({ validationModelId: " " }), + NVIDIA_DEFAULT_VALIDATION_MODEL + ); }); diff --git a/tests/unit/opencode-go-effort-aliases-8353.test.ts b/tests/unit/opencode-go-effort-aliases-8353.test.ts index 762de5fcd2..c743623f93 100644 --- a/tests/unit/opencode-go-effort-aliases-8353.test.ts +++ b/tests/unit/opencode-go-effort-aliases-8353.test.ts @@ -283,12 +283,3 @@ test("#10788 registry base rows declare the same tiers EFFORT_TIERS parses", () } } }); - -test("#10788 nvidia z-ai/glm-5.2 declares reasoning with an empty tier list (binary switch)", () => { - const entry = REGISTRY["nvidia"]; - assert.ok(entry?.models, "nvidia must expose models"); - const row = entry.models.find((m) => m.id === "z-ai/glm-5.2"); - assert.ok(row, "nvidia z-ai/glm-5.2 must exist"); - assert.equal(row.supportsReasoning, true); - assert.deepEqual(row.supportedThinkingEfforts, []); -}); From 82c64d76d32fa4e4469e37f529e46372bd3e5db5 Mon Sep 17 00:00:00 2001 From: backryun Date: Thu, 3 Sep 2026 19:50:22 +0900 Subject: [PATCH 11/47] feat(providers): modernize CLOVA Studio chat and embeddings (#12277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com #12524, #12538, #12277 e #12367 sobre o tip de release/v3.8.51: os quatro boardaram sem conflito (áreas disjuntas — zai-web, nvidia, clova, cursor/devin/fable). typecheck:core limpo, check:provider-consistency OK (272 entradas REGISTRY, 355 providers canônicos), check:known-symbols OK, e 305/305 nos testes tocados pelos quatro PRs. Os IDs de modelo adicionados foram conferidos individualmente. Obrigado, @backryun. --- open-sse/config/embeddingRegistry.ts | 128 +- .../providers/registry/clova-studio/index.ts | 72 +- open-sse/executors/clova-studio.ts | 12 + open-sse/executors/index.ts | 1 + open-sse/handlers/embeddingStructuredInput.ts | 52 +- open-sse/handlers/embeddings.ts | 1090 +++++++++-------- open-sse/translator/bootstrap.ts | 2 + open-sse/translator/formats.ts | 2 + .../translator/request/openai-to-clova.ts | 375 ++++++ .../translator/response/clova-to-openai.ts | 354 ++++++ scripts/check/check-known-symbols.ts | 3 + .../constants/providers/apikey/regional.ts | 2 +- src/shared/constants/visionModels.ts | 5 + tests/snapshots/executors/executor-map.json | 7 +- tests/snapshots/provider/translate-path.json | 6 +- tests/unit/embedding-clova-v2.test.ts | 258 ++++ tests/unit/translator-clova-v3.test.ts | 825 +++++++++++++ 17 files changed, 2638 insertions(+), 556 deletions(-) create mode 100644 open-sse/executors/clova-studio.ts create mode 100644 open-sse/translator/request/openai-to-clova.ts create mode 100644 open-sse/translator/response/clova-to-openai.ts create mode 100644 tests/unit/embedding-clova-v2.test.ts create mode 100644 tests/unit/translator-clova-v3.test.ts diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index c4ab2dc2fd..562d2f63c8 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -10,6 +10,7 @@ export type EmbeddingModality = "text" | "image" | "audio" | "video" | "document"; export type StructuredEmbeddingProtocol = "jina-v1" | "gemini-embed-content"; +export type SingleTextEmbeddingProtocol = "clova-v2"; export interface EmbeddingModel { id: string; @@ -34,6 +35,13 @@ export interface EmbeddingProvider { models: EmbeddingModel[]; /** Provider-native serializer required for canonical structured input. */ structuredInputProtocol?: StructuredEmbeddingProtocol; + /** + * Set when the endpoint embeds exactly ONE text per request (`{"text": …}` → + * one vector) instead of accepting OpenAI's `input` array. A batched + * `/v1/embeddings` call is then fanned out into N sequential upstream calls and + * merged back into a single OpenAI list response. + */ + singleTextProtocol?: SingleTextEmbeddingProtocol; } export interface EmbeddingProviderNodeRow { @@ -297,6 +305,18 @@ export const EMBEDDING_PROVIDERS: Record = { ], }, + // Naver CLOVA Studio — embedding v2. The endpoint takes a single `{"text": …}` + // body and returns `{status, result:{embedding:[…1024 floats], inputTokens}}`, + // with no batch array and no `usage` object, hence `singleTextProtocol`. + "clova-studio": { + id: "clova-studio", + baseUrl: "https://clovastudio.stream.ntruss.com/v1/api-tools/embedding/v2", + authType: "apikey", + authHeader: "bearer", + singleTextProtocol: "clova-v2", + models: [{ id: "clova-embedding-v2", name: "CLOVA Embedding v2", dimensions: 1024 }], + }, + "jina-ai": { id: "jina-ai", structuredInputProtocol: "jina-v1", @@ -471,6 +491,62 @@ export function getEmbeddingProvider(providerId: string): EmbeddingProvider | nu return EMBEDDING_PROVIDERS[resolveEmbeddingProviderId(providerId)] || null; } +function findDynamicEmbeddingProvider( + modelStr: string, + dynamicProviders: EmbeddingProvider[] | undefined +): { provider: string; model: string } | null { + const match = dynamicProviders?.find((provider) => modelStr.startsWith(`${provider.id}/`)); + return match ? { provider: match.id, model: modelStr.slice(match.id.length + 1) } : null; +} + +function parsePrefixedEmbeddingModel( + modelStr: string, + slashIdx: number, + dynamicProviders: EmbeddingProvider[] | undefined +): { provider: string; model: string } { + const rawProvider = modelStr.slice(0, slashIdx); + const dynamicExact = dynamicProviders?.find((provider) => provider.id === rawProvider); + if (dynamicExact) { + return { provider: rawProvider, model: modelStr.slice(slashIdx + 1) }; + } + + const resolvedProvider = resolveEmbeddingProviderId(rawProvider); + if (EMBEDDING_PROVIDERS[resolvedProvider]) { + return { + provider: resolvedProvider, + model: normalizeProviderScopedModelId(resolvedProvider, modelStr.slice(slashIdx + 1)), + }; + } + + const hardcodedProvider = Object.keys(EMBEDDING_PROVIDERS).find((providerId) => + modelStr.startsWith(`${providerId}/`) + ); + if (hardcodedProvider) { + return { + provider: hardcodedProvider, + model: normalizeProviderScopedModelId( + hardcodedProvider, + modelStr.slice(hardcodedProvider.length + 1) + ), + }; + } + + return ( + findDynamicEmbeddingProvider(modelStr, dynamicProviders) ?? { + provider: rawProvider, + model: modelStr.slice(slashIdx + 1), + } + ); +} + +function findEmbeddingModelProvider(modelStr: string): string | null { + return ( + Object.entries(EMBEDDING_PROVIDERS).find(([, config]) => + config.models.some((model) => model.id === modelStr) + )?.[0] ?? null + ); +} + /** * Derive an OpenAI-compatible embeddings config for a chat provider that has NO * curated EMBEDDING_PROVIDERS entry. Works for any registry provider whose base @@ -517,59 +593,11 @@ export function parseEmbeddingModel( // Check for "provider/model" format const slashIdx = modelStr.indexOf("/"); if (slashIdx > 0) { - const rawProvider = modelStr.slice(0, slashIdx); - - // A configured provider_node whose prefix exactly equals the requested - // provider segment always wins — even when that segment is also an alias - // of a curated provider (a local node must not be hijacked by a registry - // alias). Same exact-match precedence documented for - // EMBEDDING_MODEL_ALIASES above. - const dynamicExact = - dynamicProviders && dynamicProviders.find((dp) => dp.id === rawProvider); - if (dynamicExact) { - return { provider: rawProvider, model: modelStr.slice(slashIdx + 1) }; - } - - const resolvedProvider = resolveEmbeddingProviderId(rawProvider); - - if (EMBEDDING_PROVIDERS[resolvedProvider]) { - return { - provider: resolvedProvider, - model: normalizeProviderScopedModelId(resolvedProvider, modelStr.slice(slashIdx + 1)), - }; - } - - // Phase 1: Try each hardcoded provider prefix - for (const [providerId] of Object.entries(EMBEDDING_PROVIDERS)) { - if (modelStr.startsWith(providerId + "/")) { - return { - provider: providerId, - model: normalizeProviderScopedModelId(providerId, modelStr.slice(providerId.length + 1)), - }; - } - } - // Phase 2: Try dynamic provider_nodes prefix - if (dynamicProviders) { - for (const dp of dynamicProviders) { - if (modelStr.startsWith(dp.id + "/")) { - return { provider: dp.id, model: modelStr.slice(dp.id.length + 1) }; - } - } - } - // Phase 3: Fallback — first segment is provider - const provider = modelStr.slice(0, slashIdx); - const model = modelStr.slice(slashIdx + 1); - return { provider, model }; + return parsePrefixedEmbeddingModel(modelStr, slashIdx, dynamicProviders); } // No provider prefix — search hardcoded providers for the model - for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) { - if (config.models.some((m) => m.id === modelStr)) { - return { provider: providerId, model: modelStr }; - } - } - - return { provider: null, model: modelStr }; + return { provider: findEmbeddingModelProvider(modelStr), model: modelStr }; } /** diff --git a/open-sse/config/providers/registry/clova-studio/index.ts b/open-sse/config/providers/registry/clova-studio/index.ts index 8344ebe911..dd371a0336 100644 --- a/open-sse/config/providers/registry/clova-studio/index.ts +++ b/open-sse/config/providers/registry/clova-studio/index.ts @@ -1,17 +1,75 @@ import type { RegistryEntry } from "../../shared.ts"; +/** + * Naver CLOVA Studio — Chat Completions **v3** (native API). + * + * Previously this entry pointed at Naver's OpenAI-compatibility shim + * (`/v1/openai/chat/completions`), which meant `format: "openai"` and a + * pass-through `DefaultExecutor`. The v3 API is Naver's own wire format, so the + * entry now uses `format: "clova"` and the translator pair + * (`openai-to-clova` / `clova-to-openai`). + * + * v3 moves the model into the URL path (`/v3/chat-completions/{modelName}`), uses + * camelCase sampling params, and returns a `{status, result}` envelope instead of + * an OpenAI `choices[]` body — see the translators for the exact mapping. + * + * All three v3 models are live-verified against the real API (2026-09-01): + * + * | Model | Surface | Notes | + * | ------------- | -------- | -------------------------------------------------------- | + * | HCX-007 | thinking | rejects `maxTokens` (use `maxCompletionTokens`); no vision | + * | HCX-005 | text+img | vision via public URL **or** inline base64 data URI | + * | HCX-DASH-002 | text | lightweight, text only | + * + * Docs: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3 + * https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3-thinking + */ export const clova_studioProvider: RegistryEntry = { id: "clova-studio", alias: "clova", - format: "openai", - executor: "default", - baseUrl: "https://clovastudio.stream.ntruss.com/v1/openai/chat/completions", + format: "clova", + executor: "clova-studio", + baseUrl: "https://clovastudio.stream.ntruss.com/v3/chat-completions", authType: "apikey", authHeader: "bearer", + /** + * The v3 API does answer non-streaming requests (`Accept: application/json`), + * but only the streaming surface is expressed in the translator: CLOVA's SSE + * frames carry incremental `token` events plus a terminal `result` event that + * repeats the full text. Forcing the upstream stream lets OmniRoute consume + * that single, well-tested path and accumulate it into a JSON body for + * non-streaming clients, instead of maintaining a second parser for the + * `{status, result}` envelope. + */ + forceStream: true, models: [ - // HCX-007 stays first so it remains the provider default (deep-reasoning - // flagship); HCX-005 is the multimodal option. - { id: "HCX-007", name: "HCX-007" }, - { id: "HCX-005", name: "HCX-005" }, + { + // Reasoning flagship. Input+output ≤ 128000 tokens; the output cap counts + // thinking tokens too, so `maxCompletionTokens` may be up to 32768. + id: "HCX-007", + name: "HCX-007", + contextLength: 128000, + maxOutputTokens: 32768, + supportsReasoning: true, + }, + { + // HyperCLOVA X vision model. Input+output ≤ 128000 tokens, output ≤ 4096, + // up to 5 images per request (1 per turn). Accepts a public URL or an + // inline base64 data URI — the data URI must keep its + // `data:;base64,` prefix inside `dataUri.data` or the request is + // rejected with `40001 Invalid parameter`. + id: "HCX-005", + name: "HCX-005", + contextLength: 128000, + maxOutputTokens: 4096, + supportsVision: true, + }, + { + // Lightweight model. Input+output ≤ 32000 tokens, output ≤ 4096, text only. + id: "HCX-DASH-002", + name: "HCX-DASH-002", + contextLength: 32000, + maxOutputTokens: 4096, + }, ], }; diff --git a/open-sse/executors/clova-studio.ts b/open-sse/executors/clova-studio.ts new file mode 100644 index 0000000000..28997a78b9 --- /dev/null +++ b/open-sse/executors/clova-studio.ts @@ -0,0 +1,12 @@ +import { DefaultExecutor } from "./default.ts"; + +/** CLOVA Chat Completions v3 places the selected model in the URL path. */ +export class ClovaStudioExecutor extends DefaultExecutor { + constructor() { + super("clova-studio"); + } + + buildUrl(model: string): string { + return `${this.config.baseUrl}/${encodeURIComponent(model)}`; + } +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index d1c3b9ced0..fed0a651c1 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -180,6 +180,7 @@ const lazyExecutors: Record Promise> = { xai: () => import("./xai.ts").then((m) => new m.XaiExecutor()), "xai-oauth": () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")), xao: () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")), + "clova-studio": () => import("./clova-studio.ts").then((m) => new m.ClovaStudioExecutor()), "conol-web": () => import("./conol-web.ts").then((m) => new m.ConolWebExecutor()), cnl: () => import("./conol-web.ts").then((m) => new m.ConolWebExecutor()), // Alias }; diff --git a/open-sse/handlers/embeddingStructuredInput.ts b/open-sse/handlers/embeddingStructuredInput.ts index 1183c9e5a3..1cd7571a38 100644 --- a/open-sse/handlers/embeddingStructuredInput.ts +++ b/open-sse/handlers/embeddingStructuredInput.ts @@ -128,10 +128,7 @@ export async function prepareJinaMixedEmbeddingInput( continue; } if (isCanonicalEmbeddingItem(item)) { - const [translated] = await prepareJinaInput( - [item as EmbeddingMultimodalItem], - fetchMedia - ); + const [translated] = await prepareJinaInput([item as EmbeddingMultimodalItem], fetchMedia); out.push(translated); continue; } @@ -163,7 +160,9 @@ function embeddingValues(entry: unknown): unknown[] { return Array.isArray(values) ? values : []; } -function normalizeGeminiEmbedContentResponse(data: Record): Record { +function normalizeGeminiEmbedContentResponse( + data: Record +): Record { return { object: "list", data: [{ object: "embedding", embedding: embeddingValues(data.embedding), index: 0 }], @@ -263,10 +262,7 @@ async function itemToGeminiContent( return { parts: [await jinaDocToGeminiPart(item, fetchMedia)] }; } if (isCanonicalEmbeddingItem(item)) { - const [part] = await prepareGeminiParts( - [item as EmbeddingMultimodalItem], - fetchMedia - ); + const [part] = await prepareGeminiParts([item as EmbeddingMultimodalItem], fetchMedia); return { parts: [part] }; } throw new Error("Unsupported Gemini embedding input item"); @@ -346,3 +342,41 @@ export async function prepareStructuredEmbeddingRequest( } throw new Error(`Provider ${provider.id} has no structured embedding input translator`); } + +/** + * Normalize a single-text embedding endpoint's response into OpenAI's + * `/v1/embeddings` list shape. + * + * CLOVA Studio's embedding v2 answers: + * + * ``` + * {"status":{"code":"20000","message":"OK"}, + * "result":{"embedding":[…1024 floats],"inputTokens":4}} + * ``` + * + * There is no `data[]` and no `usage` object, so both are synthesized. `index` is + * left at 0 here — the batching loop in `embeddings.ts` rewrites it to the + * caller's position before the response is returned. + * + * A non-20000 status or malformed success envelope throws so an HTTP-200 error + * envelope can never be exposed as an empty successful embedding response. + */ +export function normalizeClovaEmbeddingV2Response( + rawData: Record +): Record { + const statusCode = (rawData?.status as { code?: unknown } | undefined)?.code; + if (String(statusCode) !== "20000") { + throw new Error("CLOVA Studio embedding v2 returned an unsuccessful status"); + } + + const result = (rawData?.result ?? {}) as Record; + if (!Array.isArray(result.embedding)) { + throw new Error("CLOVA Studio embedding v2 response is missing an embedding vector"); + } + + const inputTokens = Number(result.inputTokens) || 0; + return { + data: [{ object: "embedding", index: 0, embedding: result.embedding }], + usage: { prompt_tokens: inputTokens, total_tokens: inputTokens }, + }; +} diff --git a/open-sse/handlers/embeddings.ts b/open-sse/handlers/embeddings.ts index 7cf322610d..c60842b09f 100644 --- a/open-sse/handlers/embeddings.ts +++ b/open-sse/handlers/embeddings.ts @@ -1,16 +1,8 @@ /** * Embedding Handler * - * Handles POST /v1/embeddings requests. - * Proxies to upstream embedding providers using OpenAI-compatible format. - * - * Request format (OpenAI-compatible): - * { - * "model": "nebius/Qwen/Qwen3-Embedding-8B", - * "input": "text" | ["text1", "text2"], - * "dimensions": 4096, // optional - * "encoding_format": "float" // optional - * } + * Handles POST /v1/embeddings requests and normalizes provider responses to the + * OpenAI embedding shape. */ import { @@ -32,6 +24,7 @@ import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; import { hasStructuredEmbeddingInput, + normalizeClovaEmbeddingV2Response, prepareJinaMixedEmbeddingInput, prepareStructuredEmbeddingRequest, } from "./embeddingStructuredInput.ts"; @@ -53,17 +46,82 @@ interface ClientRawRequest { headers: Record; } -/** - * Flatten a single embedding item's vector to the OpenAI-spec `number[]` shape. - * - * Some OpenAI-compatible embedding backends — notably a llama.cpp - * `llama-server --embedding --pooling ...` instance — return each vector wrapped in one - * extra array level: `[[...floats]]` instead of `[...floats]` for a single input. That - * extra level is silently spec-breaking, since a standard OpenAI-SDK consumer reading - * `response.data[i].embedding` gets a length-1 array holding the real vector instead of - * the vector itself. Unwrap only that single redundant level; vectors that are already - * flat (or genuinely multi-row) are left untouched. See issue #9089. - */ +interface EmbeddingCredentials { + apiKey?: string | null; + accessToken?: string | null; + providerSpecificData?: Record | null; +} + +interface EmbeddingLog { + info: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +interface HandleEmbeddingParams { + body: Record; + credentials: EmbeddingCredentials | null; + log?: EmbeddingLog; + resolvedProvider?: EmbeddingProvider | null; + resolvedModel?: string | null; + clientRawRequest?: ClientRawRequest | null; + apiKeyId?: string | null; + apiKeyName?: string | null; + connectionId?: string | null; +} + +interface EmbeddingFailure { + success: false; + status: number; + error: string; + headers?: Headers; + data?: never; +} + +interface EmbeddingSuccess { + success: true; + data: Record; + headers: Headers; + status?: never; + error?: never; +} + +type EmbeddingResult = EmbeddingSuccess | EmbeddingFailure; + +interface ResolvedEmbedding { + provider: string | null; + model: string | null; + providerConfig: EmbeddingProvider | null; +} + +type RequestLogger = Awaited>; +type ProviderResponseNormalizer = + ((data: Record) => Record) | null; + +interface EmbeddingRuntime extends HandleEmbeddingParams { + provider: string; + model: string | null; + providerConfig: EmbeddingProvider; + startTime: number; + detailedLoggingEnabled: boolean; + reqLogger: RequestLogger; + logRequestBody: Record; +} + +interface PreparedEmbeddingRequest { + upstreamBody: Record; + upstreamUrl: string; + headers: Record; + normalizeProviderResponse: ProviderResponseNormalizer; +} + +interface ParsedEmbeddingResponse { + data?: unknown[] | unknown; + usage?: { prompt_tokens?: number; total_tokens?: number }; +} + +const KNOWN_EMBEDDING_FIELDS = new Set(["model", "input", "dimensions", "encoding_format"]); + +/** Unwrap one redundant row around an otherwise flat vector. */ function flattenSingleRowEmbedding(item: unknown): void { if (!item || typeof item !== "object" || !("embedding" in item)) return; const record = item as { embedding: unknown }; @@ -78,103 +136,81 @@ function flattenSingleRowEmbedding(item: unknown): void { } } -/** - * Handle embedding request. - * Supports both hardcoded cloud providers and dynamic local provider_nodes. - * When resolvedProvider is passed, uses it directly (injection pattern from route handler). - * Falls back to hardcoded registry lookup for backward compatibility. - */ -export async function handleEmbedding({ - body, - credentials, - log, - resolvedProvider = null, - resolvedModel = null, - clientRawRequest = null, - apiKeyId = null, - apiKeyName = null, - connectionId = null, -}: { - body: Record; - credentials: { - apiKey?: string | null; - accessToken?: string | null; - providerSpecificData?: Record | null; - } | null; - log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; - resolvedProvider?: EmbeddingProvider | null; - resolvedModel?: string | null; - clientRawRequest?: ClientRawRequest | null; - apiKeyId?: string | null; - apiKeyName?: string | null; - connectionId?: string | null; -}) { - // Use pre-resolved provider/model from route handler if available (supports dynamic provider_nodes). - let provider: string | null; - let model: string | null; - let providerConfig: EmbeddingProvider | null; +function failure(status: number, error: string, headers?: Headers): EmbeddingFailure { + return { success: false, status, error, ...(headers ? { headers } : {}) }; +} - if (resolvedProvider) { - provider = resolvedProvider.id; - model = resolvedModel; - providerConfig = resolvedProvider; - } else { - const parsed = parseEmbeddingModel(body.model as string); - provider = parsed.provider; - model = parsed.model; - providerConfig = provider ? getEmbeddingProvider(provider) : null; +function resolveEmbedding(params: HandleEmbeddingParams): ResolvedEmbedding { + if (params.resolvedProvider) { + return { + provider: params.resolvedProvider.id, + model: params.resolvedModel ?? null, + providerConfig: params.resolvedProvider, + }; } + const parsed = parseEmbeddingModel(params.body.model as string); + return { + provider: parsed.provider, + model: parsed.model, + providerConfig: parsed.provider ? getEmbeddingProvider(parsed.provider) : null, + }; +} - const startTime = Date.now(); - - // Set up request logger for pipeline artifact capture +async function createEmbeddingRuntime( + params: HandleEmbeddingParams, + resolved: ResolvedEmbedding +): Promise { const detailedLoggingEnabled = await isDetailedLoggingEnabled(); - const captureStreamChunks = getCallLogPipelineCaptureStreamChunks(); const reqLogger = await createRequestLogger( - provider || "openai", + resolved.provider || "openai", "openai", - body.model as string, + params.body.model as string, { enabled: detailedLoggingEnabled, - captureStreamChunks, - connectionId: connectionId || undefined, - model: model || (body.model as string), - provider: provider || undefined, + captureStreamChunks: getCallLogPipelineCaptureStreamChunks(), + connectionId: params.connectionId || undefined, + model: resolved.model || (params.body.model as string), + provider: resolved.provider || undefined, } ); - // Log client raw request - if (clientRawRequest) { + if (params.clientRawRequest) { reqLogger.logClientRawRequest( - clientRawRequest.endpoint, - clientRawRequest.body, - clientRawRequest.headers + params.clientRawRequest.endpoint, + params.clientRawRequest.body, + params.clientRawRequest.headers ); } + if (!resolved.provider) { + return failure( + 400, + `Invalid embedding model: ${params.body.model}. Use format: provider/model` + ); + } + if (!resolved.providerConfig) { + return failure(400, `Unknown embedding provider: ${resolved.provider}`); + } - // Summarized request body for call log (avoid storing large embedding input arrays) - const logRequestBody = { - model: body.model, - input_count: Array.isArray(body.input) ? body.input.length : 1, - dimensions: body.dimensions || undefined, + return { + ...params, + provider: resolved.provider, + model: resolved.model, + providerConfig: resolved.providerConfig, + startTime: Date.now(), + detailedLoggingEnabled, + reqLogger, + logRequestBody: { + model: params.body.model, + input_count: Array.isArray(params.body.input) ? params.body.input.length : 1, + dimensions: params.body.dimensions || undefined, + }, }; +} - if (!provider) { - return { - success: false, - status: 400, - error: `Invalid embedding model: ${body.model}. Use format: provider/model`, - }; - } - - if (!providerConfig) { - return { - success: false, - status: 400, - error: `Unknown embedding provider: ${provider}`, - }; - } - +function collectRequestedModalities(body: Record): { + structuredItems: Array<{ type: EmbeddingModality }>; + nativeModalities: EmbeddingModality[]; +} { const structuredItems = Array.isArray(body.input) ? body.input.filter( (item): item is { type: EmbeddingModality } => @@ -184,409 +220,493 @@ export async function handleEmbedding({ const nativeModalities = [ ...(isJinaNativeEmbeddingInput(body.input) ? collectJinaNativeModalities(body.input) : []), ...(isGeminiNativeEmbeddingInput(body.input) ? collectGeminiNativeModalities(body.input) : []), - ].filter((modality) => modality !== "text"); - if (structuredItems.length > 0 || nativeModalities.length > 0) { - const supportedModalities = getEmbeddingModelModalities(providerConfig, model); - if (!supportedModalities) { - return { - success: false, - status: 400, - error: `Embedding model ${body.model} does not advertise structured embedding input support`, - }; - } - const unsupportedCanonical = structuredItems.find( - (item) => !supportedModalities.includes(item.type) + ].filter((modality): modality is EmbeddingModality => modality !== "text"); + return { structuredItems, nativeModalities }; +} + +function validateRequestedModalities(runtime: EmbeddingRuntime): EmbeddingFailure | null { + const { structuredItems, nativeModalities } = collectRequestedModalities(runtime.body); + if (structuredItems.length === 0 && nativeModalities.length === 0) return null; + + const supported = getEmbeddingModelModalities(runtime.providerConfig, runtime.model); + if (!supported) { + return failure( + 400, + `Embedding model ${runtime.body.model} does not advertise structured embedding input support` ); - if (unsupportedCanonical) { - return { - success: false, - status: 400, - error: `Embedding model ${body.model} does not support ${unsupportedCanonical.type} input`, - }; - } - const unsupportedNative = nativeModalities.find( - (modality) => !supportedModalities.includes(modality) - ); - if (unsupportedNative) { - return { - success: false, - status: 400, - error: `Embedding model ${body.model} does not support ${unsupportedNative} input`, - }; - } } + const unsupportedCanonical = structuredItems.find((item) => !supported.includes(item.type)); + if (unsupportedCanonical) { + return failure( + 400, + `Embedding model ${runtime.body.model} does not support ${unsupportedCanonical.type} input` + ); + } + const unsupportedNative = nativeModalities.find((modality) => !supported.includes(modality)); + return unsupportedNative + ? failure( + 400, + `Embedding model ${runtime.body.model} does not support ${unsupportedNative} input` + ) + : null; +} - // Build upstream request — start with standard fields, then forward extra fields - // the client sent (e.g. input_type, user, truncate for NVIDIA NIM asymmetric models). - const KNOWN_FIELDS = new Set(["model", "input", "dimensions", "encoding_format"]); - - let upstreamBody: Record = { - model: model, - input: body.input, +function buildUpstreamBody(runtime: EmbeddingRuntime): Record { + const upstreamBody: Record = { + model: runtime.model, + input: runtime.body.input, }; - - if (body.dimensions !== undefined) upstreamBody.dimensions = body.dimensions; - if (body.encoding_format !== undefined) upstreamBody.encoding_format = body.encoding_format; - - for (const [key, value] of Object.entries(body)) { - if (!KNOWN_FIELDS.has(key) && value !== undefined) { - upstreamBody[key] = value; - } + if (runtime.body.dimensions !== undefined) upstreamBody.dimensions = runtime.body.dimensions; + if (runtime.body.encoding_format !== undefined) { + upstreamBody.encoding_format = runtime.body.encoding_format; + } + for (const [key, value] of Object.entries(runtime.body)) { + if (!KNOWN_EMBEDDING_FIELDS.has(key) && value !== undefined) upstreamBody[key] = value; } - // Gemini embedding models (gemini-embedding-001 / -2-preview / text-embedding-004) - // default to 3072-dim vectors. Clients targeting pgvector-style schemas typically - // request a smaller size (e.g. 1536) via OpenAI's `dimensions` field, but Google's - // OpenAI-compatibility shim at /v1beta/openai/embeddings does not document the - // `dimensions` → `outputDimensionality` translation. Mirror the request value into - // the Gemini-native `outputDimensionality` field so the upstream actually returns - // the requested vector size. Ported from upstream decolua/9router#1366. - if (provider === "gemini" && upstreamBody.outputDimensionality === undefined) { - const outputDimensionality = Number(body.dimensions); + if (runtime.provider === "gemini" && upstreamBody.outputDimensionality === undefined) { + const outputDimensionality = Number(runtime.body.dimensions); if (Number.isFinite(outputDimensionality) && outputDimensionality > 0) { upstreamBody.outputDimensionality = outputDimensionality; } } - - // Inject model-level default params (e.g. NVIDIA NIM asymmetric models require - // `input_type`) only for keys the client did not already supply, so a - // client-sent value is never overwritten. Symmetric models carry no defaults - // and are unaffected. See issue #1378. - const defaultParams = getEmbeddingModelDefaultParams(providerConfig, model); - if (defaultParams) { - for (const [key, value] of Object.entries(defaultParams)) { - if (upstreamBody[key] === undefined) { - upstreamBody[key] = value; - } - } + const defaultParams = getEmbeddingModelDefaultParams(runtime.providerConfig, runtime.model); + for (const [key, value] of Object.entries(defaultParams ?? {})) { + if (upstreamBody[key] === undefined) upstreamBody[key] = value; } + return upstreamBody; +} - let upstreamUrl = providerConfig.baseUrl; - if (provider === "ollama-local" || provider === "lmstudio") { - // Keyless local servers (#2824 ollama-local, #11233 lmstudio): honor the - // configured connection's baseUrl when one was hydrated, and fall back to - // the static localhost registry default otherwise. - const configuredBaseUrl = credentials?.providerSpecificData?.baseUrl; - const rawBaseUrl = - typeof configuredBaseUrl === "string" && configuredBaseUrl.trim().length > 0 - ? configuredBaseUrl - : providerConfig.baseUrl; - // Use the shared O(n) helper instead of `/\/+$/` — that regex is - // vulnerable to polynomial backtracking on adversarial input - // (CodeQL js/polynomial-redos) since baseUrl is operator-configured - // per-connection data. See open-sse/utils/urlSanitize.ts. - const normalizedBaseUrl = stripTrailingSlashes(rawBaseUrl.trim()); - const localServerHost = normalizedBaseUrl - .replace(/\/v1\/(?:chat\/completions|embeddings)$/i, "") - .replace(/\/api\/chat$/i, "") - .replace(/\/v1$/i, ""); - upstreamUrl = `${localServerHost}/v1/embeddings`; - } - let normalizeProviderResponse: - ((data: Record) => Record) | null = null; +function resolveLocalEmbeddingUrl(runtime: EmbeddingRuntime): string { + const configuredBaseUrl = runtime.credentials?.providerSpecificData?.baseUrl; + const rawBaseUrl = + typeof configuredBaseUrl === "string" && configuredBaseUrl.trim() + ? configuredBaseUrl + : runtime.providerConfig.baseUrl; + const localServerHost = stripTrailingSlashes(rawBaseUrl.trim()) + .replace(/\/v1\/(?:chat\/completions|embeddings)$/i, "") + .replace(/\/api\/chat$/i, "") + .replace(/\/v1$/i, ""); + return `${localServerHost}/v1/embeddings`; +} - // Build headers - const headers: Record = { - "Content-Type": "application/json", - }; +function resolveUpstreamUrl(runtime: EmbeddingRuntime): string { + return runtime.provider === "ollama-local" || runtime.provider === "lmstudio" + ? resolveLocalEmbeddingUrl(runtime) + : runtime.providerConfig.baseUrl; +} - // Skip credential injection for local providers (authType: "none") +function buildAuth( + runtime: EmbeddingRuntime +): { headers: Record; token: string | null } | EmbeddingFailure { + const headers: Record = { "Content-Type": "application/json" }; const token = - providerConfig.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken; - if (token) { - if (providerConfig.authHeader === "bearer") { - headers["Authorization"] = `Bearer ${token}`; - } else if (providerConfig.authHeader === "x-api-key") { - headers["x-api-key"] = token; - } - } else if (providerConfig.authType !== "none") { - return { - success: false, - status: 401, - error: `No valid authentication token for provider ${provider}. Check provider credentials.`, - }; - } - - // Jina v5 Omni native docs ({ text }, { image: url|base64 }, { content: [...] }) - // must reach api.jina.ai unchanged. Do not fetch those image URLs or collapse - // to string[]. Canonical { type, source } items still go through the translator. - const jinaNative = isJinaNativeEmbeddingInput(body.input); - const geminiNative = isGeminiNativeEmbeddingInput(body.input); - const canonicalStructured = hasStructuredEmbeddingInput(body.input); - const passThroughJinaNative = - providerConfig.structuredInputProtocol === "jina-v1" && jinaNative && !canonicalStructured; - // gemini-embedding-2 aggregates a string[] on Google's OpenAI shim into one - // vector. Always use embedContent / batchEmbedContents so N input items - // become N embeddings. Native multimodal parts take the same path. - const useGeminiNativeTransport = - providerConfig.structuredInputProtocol === "gemini-embed-content" && - (isGeminiEmbedding2Family(model) || canonicalStructured || geminiNative || jinaNative); - - if (providerConfig.structuredInputProtocol === "jina-v1" && jinaNative && canonicalStructured) { - try { - const mixed = Array.isArray(body.input) ? body.input : [body.input]; - upstreamBody.input = await prepareJinaMixedEmbeddingInput(mixed, async (url) => { - const result = await fetchRemoteImage(url, { - guard: "public-only", - maxBytes: MAX_EMBEDDING_INLINE_ITEM_BYTES, - pinDns: true, - }); - return { buffer: result.buffer, contentType: result.contentType || null }; - }); - } catch (error) { - return { success: false, status: 400, error: sanitizeErrorMessage(error) }; - } - } else if (useGeminiNativeTransport || (!passThroughJinaNative && canonicalStructured)) { - if (!model) { - return { - success: false, - status: 400, - error: `Invalid embedding model: ${body.model}. Use format: provider/model`, - }; - } - try { - const prepared = await prepareStructuredEmbeddingRequest( - providerConfig, - model, - body, - token ?? "", - { - fetchMedia: async (url) => { - const result = await fetchRemoteImage(url, { - guard: "public-only", - maxBytes: MAX_EMBEDDING_INLINE_ITEM_BYTES, - pinDns: true, - }); - return { buffer: result.buffer, contentType: result.contentType || null }; - }, - } - ); - upstreamBody = prepared.body; - upstreamUrl = prepared.url; - normalizeProviderResponse = prepared.normalizeResponse ?? null; - if (prepared.authHeader) { - delete headers.Authorization; - delete headers["x-api-key"]; - headers[prepared.authHeader.name] = prepared.authHeader.value; - } - } catch (error) { - return { success: false, status: 400, error: sanitizeErrorMessage(error) }; - } - } - - if (log) { - log.info( - "EMBED", - `${provider}/${model} | input: ${Array.isArray(body.input) ? body.input.length + " items" : "1 item"}` + runtime.providerConfig.authType === "none" + ? null + : runtime.credentials?.apiKey || runtime.credentials?.accessToken || null; + if (!token && runtime.providerConfig.authType !== "none") { + return failure( + 401, + `No valid authentication token for provider ${runtime.provider}. Check provider credentials.` ); } + if (token && runtime.providerConfig.authHeader === "bearer") { + headers.Authorization = `Bearer ${token}`; + } else if (token && runtime.providerConfig.authHeader === "x-api-key") { + headers["x-api-key"] = token; + } + return { headers, token }; +} - try { - // Quota share enforcement (fail-open: errors allow the request through) - if (apiKeyId && connectionId && provider) { - try { - const { enforceQuotaShare } = await import("@/lib/quota/enforce"); - const quotaDecision = await enforceQuotaShare({ - apiKeyId, - connectionId, - provider, - // Per-(key,model) cap — resolved embedding model id (same scope used in logs/routing). - model: model || undefined, - }); - if (quotaDecision.kind === "block") { - return { - success: false, - status: quotaDecision.httpStatus ?? 429, - error: quotaDecision.reason || "Quota share limit reached", - }; - } - } catch { - // fail-open per B16 - } - } +async function fetchEmbeddingMedia( + url: string +): Promise<{ buffer: Buffer; contentType: string | null }> { + const result = await fetchRemoteImage(url, { + guard: "public-only", + maxBytes: MAX_EMBEDDING_INLINE_ITEM_BYTES, + pinDns: true, + }); + return { buffer: result.buffer, contentType: result.contentType || null }; +} - // Log provider request - reqLogger.logTargetRequest(upstreamUrl, headers, upstreamBody); +async function prepareMixedJinaInput( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest +): Promise { + const mixed = Array.isArray(runtime.body.input) ? runtime.body.input : [runtime.body.input]; + prepared.upstreamBody.input = await prepareJinaMixedEmbeddingInput(mixed, fetchEmbeddingMedia); +} - const response = await fetch(upstreamUrl, { - method: "POST", - headers, - body: JSON.stringify(upstreamBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - if (log) { - log.error("EMBED", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); - } - - // Log provider response - reqLogger.logProviderResponse(response.status, "", response.headers, errorText.slice(0, 500)); - - // Build client error response - const clientErrorBody = toJsonErrorPayload( - errorText.slice(0, 500), - "Embedding provider error" - ); - reqLogger.logConvertedResponse(clientErrorBody); - - const pipelinePayloads = detailedLoggingEnabled ? reqLogger.getPipelinePayloads() : null; - - // Save error call log for Logger panel - saveCallLog({ - method: "POST", - path: "/v1/embeddings", - status: response.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - requestBody: logRequestBody, - pipelinePayloads, - apiKeyId, - apiKeyName, - connectionId, - }).catch(() => {}); - - // #10347 — persist a connection-level failure marker on a hard upstream failure so - // the dead account is not re-selected and re-hit on the next embed request (chat - // parity). markAccountUnavailable classifies the status via checkFallbackError: a - // payment-required 402 becomes the TERMINAL state credits_exhausted (the terminal - // marker excludes the account from selection until an operator resets it), benign - // 4xx are a no-op, and terminal statuses are never overwritten. honors per-connection - // disableCooling. The write must never break the error response path, so it is - // best-effort. - if (connectionId) { - try { - await markAccountUnavailable(connectionId, response.status, errorText, provider, model); - } catch { - // swallow — the upstream error response takes priority - } - } - - return { - success: false, - status: response.status, - error: errorText, - headers: stripStaleEncodingHeaders(response.headers), - }; - } - - const rawData = (await response.json()) as Record; - const data = (normalizeProviderResponse ? normalizeProviderResponse(rawData) : rawData) as { - data?: unknown[] | unknown; - usage?: { prompt_tokens?: number; total_tokens?: number }; - }; - - // Log provider response - reqLogger.logProviderResponse(response.status, "", response.headers, data); - - // OpenAI-spec compliance (#9089): each item's `embedding` must be a flat number[]. - // Some OpenAI-compatible backends (e.g. a llama.cpp `llama-server --embedding` - // instance) return the vector wrapped in one extra array level — `[[...floats]]` - // instead of `[...floats]` — for a single input, which silently breaks any standard - // OpenAI-SDK consumer doing `response.data[i].embedding`. Flatten that one redundant - // level without touching providers that already return flat vectors. - const responseItems = data.data || data; - if (Array.isArray(responseItems)) { - for (const item of responseItems) { - flattenSingleRowEmbedding(item); - } - } - - // Normalize response to OpenAI format - const normalizedResponse = { - object: "list", - data: data.data || data, - model: `${provider}/${model}`, - usage: data.usage || { prompt_tokens: 0, total_tokens: 0 }, - }; - - // Log client response - reqLogger.logConvertedResponse(normalizedResponse); - - const pipelinePayloads = detailedLoggingEnabled ? reqLogger.getPipelinePayloads() : null; - - // Save success call log for Logger panel - // Embeddings only have input tokens (prompt_tokens + total_tokens), no output/completion tokens - saveCallLog({ - method: "POST", - path: "/v1/embeddings", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - tokens: { - prompt_tokens: data.usage?.prompt_tokens || data.usage?.total_tokens || 0, - completion_tokens: 0, - }, - requestBody: logRequestBody, - responseBody: { - usage: data.usage || null, - object: "list", - data_count: Array.isArray(data.data) ? data.data.length : 0, - }, - pipelinePayloads, - apiKeyId, - apiKeyName, - connectionId, - }).catch(() => {}); - - // Record quota consumption (fire-and-forget, never blocks) - if (apiKeyId && connectionId && provider) { - try { - const { scheduleRecordConsumption } = await import("@/lib/quota/spendRecorder"); - scheduleRecordConsumption({ - apiKeyId, - connectionId, - provider, - // Per-(key,model) cap accounting — same resolved model id used at enforce time. - model: model || undefined, - cost: { - tokens: data.usage?.prompt_tokens || data.usage?.total_tokens || 0, - requests: 1, - }, - }); - } catch { - // fail-open per B29 - } - } - - return { - success: true, - data: normalizedResponse, - headers: stripStaleEncodingHeaders(response.headers), - }; - } catch (err) { - if (log) { - log.error("EMBED", `${provider} fetch error: ${err.message}`); - } - - // Log error - reqLogger.logError(err, upstreamBody); - - const pipelinePayloads = detailedLoggingEnabled ? reqLogger.getPipelinePayloads() : null; - - // Save exception call log for Logger panel - saveCallLog({ - method: "POST", - path: "/v1/embeddings", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: err.message, - requestBody: logRequestBody, - pipelinePayloads, - apiKeyId, - apiKeyName, - connectionId, - }).catch(() => {}); - - return { - success: false, - status: 502, - error: `Embedding provider error: ${sanitizeErrorMessage(err.message)}`, - }; +async function prepareNativeTransport( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest, + token: string | null +): Promise { + if (!runtime.model) { + throw new Error(`Invalid embedding model: ${runtime.body.model}. Use format: provider/model`); + } + const native = await prepareStructuredEmbeddingRequest( + runtime.providerConfig, + runtime.model, + runtime.body, + token ?? "", + { fetchMedia: fetchEmbeddingMedia } + ); + prepared.upstreamBody = native.body; + prepared.upstreamUrl = native.url; + prepared.normalizeProviderResponse = native.normalizeResponse ?? null; + if (native.authHeader) { + delete prepared.headers.Authorization; + delete prepared.headers["x-api-key"]; + prepared.headers[native.authHeader.name] = native.authHeader.value; } } + +async function applyStructuredTransport( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest, + token: string | null +): Promise { + const jinaNative = isJinaNativeEmbeddingInput(runtime.body.input); + const geminiNative = isGeminiNativeEmbeddingInput(runtime.body.input); + const canonical = hasStructuredEmbeddingInput(runtime.body.input); + const isJinaProtocol = runtime.providerConfig.structuredInputProtocol === "jina-v1"; + const passThroughJina = isJinaProtocol && jinaNative && !canonical; + const useGeminiNative = + runtime.providerConfig.structuredInputProtocol === "gemini-embed-content" && + (isGeminiEmbedding2Family(runtime.model) || canonical || geminiNative || jinaNative); + + if (isJinaProtocol && jinaNative && canonical) { + await prepareMixedJinaInput(runtime, prepared); + } else if (useGeminiNative || (!passThroughJina && canonical)) { + await prepareNativeTransport(runtime, prepared, token); + } +} + +async function prepareEmbeddingRequest( + runtime: EmbeddingRuntime +): Promise { + const auth = buildAuth(runtime); + if ("success" in auth) return auth; + const prepared: PreparedEmbeddingRequest = { + upstreamBody: buildUpstreamBody(runtime), + upstreamUrl: resolveUpstreamUrl(runtime), + headers: auth.headers, + normalizeProviderResponse: null, + }; + try { + await applyStructuredTransport(runtime, prepared, auth.token); + return prepared; + } catch (error) { + return failure(400, sanitizeErrorMessage(error)); + } +} + +async function enforceEmbeddingQuota(runtime: EmbeddingRuntime): Promise { + if (!runtime.apiKeyId || !runtime.connectionId) return null; + try { + const { enforceQuotaShare } = await import("@/lib/quota/enforce"); + const decision = await enforceQuotaShare({ + apiKeyId: runtime.apiKeyId, + connectionId: runtime.connectionId, + provider: runtime.provider, + model: runtime.model || undefined, + }); + return decision.kind === "block" + ? failure(decision.httpStatus ?? 429, decision.reason || "Quota share limit reached") + : null; + } catch { + return null; + } +} + +function resolveSingleTexts(runtime: EmbeddingRuntime): string[] | EmbeddingFailure | null { + if (runtime.providerConfig.singleTextProtocol !== "clova-v2") return null; + const input = Array.isArray(runtime.body.input) ? runtime.body.input : [runtime.body.input]; + if ( + input.length === 0 || + input.some((item) => typeof item !== "string" || item.trim().length === 0) + ) { + return failure(400, "CLOVA Studio embedding v2 accepts non-empty text strings only"); + } + if (runtime.body.encoding_format === "base64") { + return failure(400, "CLOVA Studio embedding v2 supports float encoding only"); + } + if (runtime.body.dimensions !== undefined && Number(runtime.body.dimensions) !== 1024) { + return failure(400, "CLOVA Studio embedding v2 has a fixed dimension of 1024"); + } + return input as string[]; +} + +function appendClovaEmbedding( + parsed: ParsedEmbeddingResponse, + embeddings: Array>, + usage: { prompt_tokens: number; total_tokens: number } +): void { + if (!Array.isArray(parsed.data)) { + throw new Error("CLOVA Studio embedding v2 returned an invalid data list"); + } + for (const item of parsed.data) { + flattenSingleRowEmbedding(item); + if (!item || typeof item !== "object") { + throw new Error("CLOVA Studio embedding v2 returned an invalid embedding item"); + } + (item as { index?: number }).index = embeddings.length; + embeddings.push(item as Record); + } + usage.prompt_tokens += parsed.usage?.prompt_tokens || parsed.usage?.total_tokens || 0; + usage.total_tokens += parsed.usage?.total_tokens || parsed.usage?.prompt_tokens || 0; +} + +async function fetchClovaEmbeddingBatch( + prepared: PreparedEmbeddingRequest, + texts: string[], + reqLogger: RequestLogger +): Promise { + const embeddings: Array> = []; + const usage = { prompt_tokens: 0, total_tokens: 0 }; + let lastHeaders = new Headers(); + for (const text of texts) { + const requestBody = { text }; + reqLogger.logTargetRequest(prepared.upstreamUrl, prepared.headers, requestBody); + const response = await fetch(prepared.upstreamUrl, { + method: "POST", + headers: prepared.headers, + body: JSON.stringify(requestBody), + }); + lastHeaders = response.headers; + if (!response.ok) return response; + const rawData = (await response.json()) as Record; + appendClovaEmbedding(normalizeClovaEmbeddingV2Response(rawData), embeddings, usage); + } + return new Response(JSON.stringify({ data: embeddings, usage }), { + status: 200, + headers: lastHeaders, + }); +} + +async function dispatchEmbeddingRequest( + prepared: PreparedEmbeddingRequest, + singleTexts: string[] | null, + reqLogger: RequestLogger +): Promise { + if (singleTexts) return fetchClovaEmbeddingBatch(prepared, singleTexts, reqLogger); + reqLogger.logTargetRequest(prepared.upstreamUrl, prepared.headers, prepared.upstreamBody); + return fetch(prepared.upstreamUrl, { + method: "POST", + headers: prepared.headers, + body: JSON.stringify(prepared.upstreamBody), + }); +} + +function pipelinePayloads( + runtime: EmbeddingRuntime +): ReturnType | null { + return runtime.detailedLoggingEnabled ? runtime.reqLogger.getPipelinePayloads() : null; +} + +async function handleUpstreamFailure( + runtime: EmbeddingRuntime, + response: Response +): Promise { + const errorText = await response.text(); + runtime.log?.error( + "EMBED", + `${runtime.provider} error ${response.status}: ${errorText.slice(0, 200)}` + ); + runtime.reqLogger.logProviderResponse( + response.status, + "", + response.headers, + errorText.slice(0, 500) + ); + runtime.reqLogger.logConvertedResponse( + toJsonErrorPayload(errorText.slice(0, 500), "Embedding provider error") + ); + saveCallLog({ + method: "POST", + path: "/v1/embeddings", + status: response.status, + model: `${runtime.provider}/${runtime.model}`, + provider: runtime.provider, + duration: Date.now() - runtime.startTime, + error: errorText.slice(0, 500), + requestBody: runtime.logRequestBody, + pipelinePayloads: pipelinePayloads(runtime), + apiKeyId: runtime.apiKeyId, + apiKeyName: runtime.apiKeyName, + connectionId: runtime.connectionId, + }).catch(() => {}); + if (runtime.connectionId) { + try { + await markAccountUnavailable( + runtime.connectionId, + response.status, + errorText, + runtime.provider, + runtime.model + ); + } catch { + // The upstream response has priority over a best-effort cooldown write. + } + } + return failure(response.status, errorText, stripStaleEncodingHeaders(response.headers)); +} + +function normalizeEmbeddingData( + runtime: EmbeddingRuntime, + response: Response, + rawData: Record, + normalizer: ProviderResponseNormalizer +): { data: ParsedEmbeddingResponse; normalizedResponse: Record } { + const data = (normalizer ? normalizer(rawData) : rawData) as ParsedEmbeddingResponse; + runtime.reqLogger.logProviderResponse(response.status, "", response.headers, data); + const responseItems = data.data || data; + if (Array.isArray(responseItems)) responseItems.forEach(flattenSingleRowEmbedding); + return { + data, + normalizedResponse: { + object: "list", + data: data.data || data, + model: `${runtime.provider}/${runtime.model}`, + usage: data.usage || { prompt_tokens: 0, total_tokens: 0 }, + }, + }; +} + +function recordEmbeddingSuccess( + runtime: EmbeddingRuntime, + data: ParsedEmbeddingResponse, + normalizedResponse: Record +): void { + runtime.reqLogger.logConvertedResponse(normalizedResponse); + saveCallLog({ + method: "POST", + path: "/v1/embeddings", + status: 200, + model: `${runtime.provider}/${runtime.model}`, + provider: runtime.provider, + duration: Date.now() - runtime.startTime, + tokens: { + prompt_tokens: data.usage?.prompt_tokens || data.usage?.total_tokens || 0, + completion_tokens: 0, + }, + requestBody: runtime.logRequestBody, + responseBody: { + usage: data.usage || null, + object: "list", + data_count: Array.isArray(data.data) ? data.data.length : 0, + }, + pipelinePayloads: pipelinePayloads(runtime), + apiKeyId: runtime.apiKeyId, + apiKeyName: runtime.apiKeyName, + connectionId: runtime.connectionId, + }).catch(() => {}); +} + +async function recordEmbeddingConsumption( + runtime: EmbeddingRuntime, + data: ParsedEmbeddingResponse, + requestCount: number +): Promise { + if (!runtime.apiKeyId || !runtime.connectionId) return; + try { + const { scheduleRecordConsumption } = await import("@/lib/quota/spendRecorder"); + scheduleRecordConsumption({ + apiKeyId: runtime.apiKeyId, + connectionId: runtime.connectionId, + provider: runtime.provider, + model: runtime.model || undefined, + cost: { + tokens: data.usage?.prompt_tokens || data.usage?.total_tokens || 0, + requests: requestCount, + }, + }); + } catch { + // Quota accounting is fail-open. + } +} + +async function handleUpstreamSuccess( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest, + response: Response, + requestCount: number +): Promise { + const rawData = (await response.json()) as Record; + const { data, normalizedResponse } = normalizeEmbeddingData( + runtime, + response, + rawData, + prepared.normalizeProviderResponse + ); + recordEmbeddingSuccess(runtime, data, normalizedResponse); + await recordEmbeddingConsumption(runtime, data, requestCount); + return { + success: true, + data: normalizedResponse, + headers: stripStaleEncodingHeaders(response.headers), + }; +} + +function handleEmbeddingException( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest, + error: unknown +): EmbeddingFailure { + const message = error instanceof Error ? error.message : String(error); + runtime.log?.error("EMBED", `${runtime.provider} fetch error: ${message}`); + runtime.reqLogger.logError(error, prepared.upstreamBody); + saveCallLog({ + method: "POST", + path: "/v1/embeddings", + status: 502, + model: `${runtime.provider}/${runtime.model}`, + provider: runtime.provider, + duration: Date.now() - runtime.startTime, + error: message, + requestBody: runtime.logRequestBody, + pipelinePayloads: pipelinePayloads(runtime), + apiKeyId: runtime.apiKeyId, + apiKeyName: runtime.apiKeyName, + connectionId: runtime.connectionId, + }).catch(() => {}); + return failure(502, `Embedding provider error: ${sanitizeErrorMessage(message)}`); +} + +async function executeEmbedding( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest +): Promise { + const quotaFailure = await enforceEmbeddingQuota(runtime); + if (quotaFailure) return quotaFailure; + const singleTextsOrFailure = resolveSingleTexts(runtime); + if (singleTextsOrFailure && !Array.isArray(singleTextsOrFailure)) return singleTextsOrFailure; + const singleTexts = Array.isArray(singleTextsOrFailure) ? singleTextsOrFailure : null; + try { + const response = await dispatchEmbeddingRequest(prepared, singleTexts, runtime.reqLogger); + return response.ok + ? handleUpstreamSuccess(runtime, prepared, response, singleTexts?.length ?? 1) + : handleUpstreamFailure(runtime, response); + } catch (error) { + return handleEmbeddingException(runtime, prepared, error); + } +} + +/** Handle one OpenAI-compatible embedding request. */ +export async function handleEmbedding(params: HandleEmbeddingParams): Promise { + const resolved = resolveEmbedding(params); + const runtime = await createEmbeddingRuntime(params, resolved); + if ("success" in runtime) return runtime; + const modalityFailure = validateRequestedModalities(runtime); + if (modalityFailure) return modalityFailure; + const prepared = await prepareEmbeddingRequest(runtime); + if ("success" in prepared) return prepared; + runtime.log?.info( + "EMBED", + `${runtime.provider}/${runtime.model} | input: ${ + Array.isArray(runtime.body.input) ? `${runtime.body.input.length} items` : "1 item" + }` + ); + return executeEmbedding(runtime, prepared); +} diff --git a/open-sse/translator/bootstrap.ts b/open-sse/translator/bootstrap.ts index df852d483c..bd870e934a 100644 --- a/open-sse/translator/bootstrap.ts +++ b/open-sse/translator/bootstrap.ts @@ -5,6 +5,7 @@ import "./request/claude-to-openai.ts"; import "./request/openai-to-claude.ts"; +import "./request/openai-to-clova.ts"; import "./request/gemini-to-openai.ts"; import "./request/openai-to-gemini.ts"; import "./request/antigravity-to-openai.ts"; @@ -15,6 +16,7 @@ import "./request/claude-to-gemini.ts"; import "./response/claude-to-openai.ts"; import "./response/openai-to-claude.ts"; +import "./response/clova-to-openai.ts"; import "./response/gemini-to-openai.ts"; import "./response/gemini-to-claude.ts"; import "./response/openai-to-antigravity.ts"; diff --git a/open-sse/translator/formats.ts b/open-sse/translator/formats.ts index 4e0bd391f0..1349d30963 100644 --- a/open-sse/translator/formats.ts +++ b/open-sse/translator/formats.ts @@ -5,6 +5,8 @@ export const FORMATS = { OPENAI_RESPONSE: "openai-response", CLAUDE: "claude", GEMINI: "gemini", + /** Naver CLOVA Studio Chat Completions v3 (native envelope, model in URL path). */ + CLOVA: "clova", CODEX: "codex", ANTIGRAVITY: "antigravity", KIRO: "kiro", diff --git a/open-sse/translator/request/openai-to-clova.ts b/open-sse/translator/request/openai-to-clova.ts new file mode 100644 index 0000000000..62bd519c66 --- /dev/null +++ b/open-sse/translator/request/openai-to-clova.ts @@ -0,0 +1,375 @@ +/** + * OpenAI → Naver CLOVA Studio "Chat Completions v3" request translator. + * + * Wire format: `POST https://clovastudio.stream.ntruss.com/v3/chat-completions/{modelName}` + * + * Everything below that is marked "live-verified" was confirmed against the real + * API on 2026-09-01 — several of these rules contradict a plausible reading of + * the vendor docs, so they are recorded with the evidence. + * + * Vendor docs: + * - text/image: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3 + * - thinking: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3-thinking + * - FC: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3-fc + * - SO: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3-so + */ +import { register } from "../registry.ts"; +import { FORMATS } from "../formats.ts"; + +/** Output cap for the non-reasoning v3 models (HCX-005, HCX-DASH-002). */ +export const CLOVA_V3_MAX_OUTPUT_TOKENS = 4096; + +/** Output cap for the reasoning model (HCX-007) — includes thinking tokens. */ +export const CLOVA_V3_REASONING_MAX_OUTPUT_TOKENS = 32768; + +/** + * Function calling rejects any cap below 1024 (live-verified: `40001 Invalid + * parameter: tools, maxTokens`). + */ +export const CLOVA_V3_MIN_TOOL_TOKENS = 1024; + +export const CLOVA_V3_REASONING_MODELS: ReadonlySet = new Set(["HCX-007"]); + +export const CLOVA_V3_VISION_MODELS: ReadonlySet = new Set(["HCX-005"]); + +/** + * All three v3 models accept function calling (live-verified). HCX-007 needs + * `thinking.effort: "none"` alongside it or the call fails with + * `40001 Invalid parameter: tools, thinking`. + */ +export const CLOVA_V3_FUNCTION_CALLING_MODELS: ReadonlySet = new Set([ + "HCX-005", + "HCX-007", + "HCX-DASH-002", +]); + +/** Structured Outputs is HCX-007 only (live-verified: HCX-005 rejects `thinking`). */ +export const CLOVA_V3_STRUCTURED_OUTPUT_MODELS: ReadonlySet = new Set(["HCX-007"]); + +const CLOVA_THINKING_EFFORTS: ReadonlySet = new Set(["none", "low", "medium", "high"]); + +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function nonEmptyString(value: unknown): string { + return typeof value === "string" && value.length > 0 ? value : ""; +} + +export function isClovaReasoningModel(model: string): boolean { + return typeof model === "string" && CLOVA_V3_REASONING_MODELS.has(model.toUpperCase()); +} + +export function isClovaVisionModel(model: string): boolean { + return typeof model === "string" && CLOVA_V3_VISION_MODELS.has(model.toUpperCase()); +} + +export function isClovaFunctionCallingModel(model: string): boolean { + return typeof model === "string" && CLOVA_V3_FUNCTION_CALLING_MODELS.has(model.toUpperCase()); +} + +export function isClovaStructuredOutputModel(model: string): boolean { + return typeof model === "string" && CLOVA_V3_STRUCTURED_OUTPUT_MODELS.has(model.toUpperCase()); +} + +function clampNumeric(value: unknown, min: number, max: number): number | null { + const n = typeof value === "string" ? Number(value) : value; + if (typeof n !== "number" || !Number.isFinite(n)) return null; + return Math.min(Math.max(n, min), max); +} + +/** + * Map OpenAI `reasoning_effort` onto CLOVA's `thinking.effort`. + * `minimal` collapses to `low`; unrecognised values are dropped so CLOVA applies + * its own default (`low`). + */ +export function toClovaThinkingEffort(reasoningEffort: unknown): string { + if (typeof reasoningEffort !== "string") return ""; + const effort = reasoningEffort.toLowerCase(); + if (effort === "minimal") return "low"; + return CLOVA_THINKING_EFFORTS.has(effort) ? effort : ""; +} + +/** Flatten OpenAI message content into a single string (text only). */ +function contentToString(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return content == null ? "" : String(content); + return content + .map((part) => + part && typeof part === "object" && typeof part.text === "string" ? part.text : "" + ) + .filter(Boolean) + .join("\n"); +} + +/** + * Convert an OpenAI `content` value into CLOVA v3 typed content parts. + * + * Both image transports work (live-verified): a public URL becomes + * `imageUrl.url`, and a `data:` URL becomes `dataUri.data` — which must keep the + * FULL `data:;base64,` prefix or CLOVA rejects the request with + * `40001 Invalid parameter`. + */ +export function toClovaContent( + content: unknown, + supportsImages: boolean +): Array> { + if (typeof content === "string") { + return [{ type: "text", text: content }]; + } + + if (!Array.isArray(content)) { + return [{ type: "text", text: content == null ? "" : String(content) }]; + } + + const parts = content + .map((part) => toClovaContentPart(part, supportsImages)) + .filter((part): part is JsonRecord => part !== null); + + // CLOVA rejects a message with an empty content array, so always emit a part. + return parts.length > 0 ? parts : [{ type: "text", text: "" }]; +} + +function toClovaContentPart(part: unknown, supportsImages: boolean): JsonRecord | null { + const record = toRecord(part); + if (!record) return null; + + const text = nonEmptyString(record.text); + if (record.type === "text" || text) return text ? { type: "text", text } : null; + if (record.type !== "image_url" || !supportsImages) return null; + + const imageUrl = toRecord(record.image_url); + const url = nonEmptyString(imageUrl?.url) || nonEmptyString(record.url); + if (!url) return null; + return url.startsWith("data:") + ? { type: "image_url", dataUri: { data: url } } + : { type: "image_url", imageUrl: { url } }; +} + +/** Parse OpenAI's JSON-string tool arguments into the object CLOVA expects. */ +function toolArgumentsToObject(raw: unknown): Record { + if (raw == null) return {}; + if (typeof raw === "object") return raw as Record; + if (typeof raw !== "string" || !raw.trim()) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +/** + * Convert OpenAI tool declarations into CLOVA's `tools` array. + * The shapes are nearly identical; empty declarations are skipped because CLOVA + * rejects a tool without a name. + */ +export function toClovaTools(tools: unknown): Array> { + if (!Array.isArray(tools)) return []; + return tools.map(toClovaTool).filter((tool): tool is JsonRecord => tool !== null); +} + +function toClovaTool(tool: unknown): JsonRecord | null { + const record = toRecord(tool); + if (!record) return null; + const fn = toRecord(record.function); + const name = nonEmptyString(fn?.name) || nonEmptyString(record.name); + if (!name) return null; + + const description = + nonEmptyString(fn?.description) || nonEmptyString(record.description) || `Tool: ${name}`; + const parameters = fn?.parameters ?? record.parameters; + return { + type: "function", + function: { + name, + description, + ...(parameters ? { parameters } : {}), + }, + }; +} + +/** + * Which mutually-exclusive v3 mode does this request use? + * + * CLOVA forbids combining function calling with thinking or images, and forbids + * combining structured outputs with either. Exactly one mode is chosen. + */ +export function resolveClovaMode( + model: string, + body: Record +): "tools" | "structured" | "plain" { + const tools = toClovaTools(body?.tools); + if (tools.length > 0 && isClovaFunctionCallingModel(model)) return "tools"; + + const format = toRecord(body?.response_format); + const wantsSchema = format && (format.type === "json_schema" || format.type === "json_object"); + if (wantsSchema && isClovaStructuredOutputModel(model)) return "structured"; + + return "plain"; +} + +type ClovaMode = "tools" | "structured" | "plain"; + +function normalizeMessageRole(role: unknown): "assistant" | "system" | "user" { + return role === "assistant" || role === "system" ? role : "user"; +} + +function toClovaToolCall(call: unknown): JsonRecord { + const record = toRecord(call) ?? {}; + const fn = toRecord(record.function); + return { + id: record.id ?? "", + type: "function", + function: { + name: fn?.name ?? record.name ?? "", + arguments: toolArgumentsToObject(fn?.arguments ?? record.arguments), + }, + }; +} + +function toClovaToolModeMessage(message: unknown): JsonRecord { + const record = toRecord(message) ?? {}; + if (record.role === "tool") { + return { + role: "tool", + content: contentToString(record.content), + ...(record.tool_call_id ? { toolCallId: String(record.tool_call_id) } : {}), + }; + } + + const toolCalls = Array.isArray(record.tool_calls) ? record.tool_calls : []; + if (record.role === "assistant" && toolCalls.length > 0) { + return { + role: "assistant", + content: "", + toolCalls: toolCalls.map(toClovaToolCall), + }; + } + return { + role: normalizeMessageRole(record.role), + content: contentToString(record.content), + }; +} + +function toClovaPlainMessage(message: unknown, supportsImages: boolean): JsonRecord { + const record = toRecord(message) ?? {}; + return { + role: normalizeMessageRole(record.role), + content: toClovaContent(record.content, supportsImages), + }; +} + +function toClovaMessages(body: JsonRecord, mode: ClovaMode, supportsImages: boolean): JsonRecord[] { + const messages = Array.isArray(body.messages) ? body.messages : []; + return messages.map((message) => + mode === "tools" + ? toClovaToolModeMessage(message) + : toClovaPlainMessage(message, supportsImages) + ); +} + +function applyThinking(payload: JsonRecord, body: JsonRecord, reasoning: boolean, mode: ClovaMode) { + if (!reasoning) return; + const effort = toClovaThinkingEffort(body.reasoning_effort); + if (mode === "tools" || mode === "structured") { + payload.thinking = { effort: "none" }; + } else if (effort) { + payload.thinking = { effort }; + } +} + +function applySampling(payload: JsonRecord, body: JsonRecord): void { + const temperature = clampNumeric(body.temperature, 0, 1); + if (temperature !== null) payload.temperature = temperature; + const topP = clampNumeric(body.top_p, 0, 1); + if (topP !== null && topP > 0) payload.topP = topP; + const topK = clampNumeric(body.top_k, 0, 128); + if (topK !== null && topK > 0) payload.topK = topK; + const penalty = clampNumeric(body.repetition_penalty, 0, 2); + if (penalty !== null && penalty > 0) payload.repetitionPenalty = penalty; +} + +function applyOutputCap( + payload: JsonRecord, + body: JsonRecord, + reasoning: boolean, + mode: ClovaMode +): void { + const cap = reasoning ? CLOVA_V3_REASONING_MAX_OUTPUT_TOKENS : CLOVA_V3_MAX_OUTPUT_TOKENS; + const key = reasoning ? "maxCompletionTokens" : "maxTokens"; + let tokens = clampNumeric(body.max_completion_tokens ?? body.max_tokens, 1, cap); + if (mode === "tools") { + const floor = Math.min(CLOVA_V3_MIN_TOOL_TOKENS, cap); + tokens = tokens === null ? floor : Math.max(tokens, floor); + } + if (tokens !== null) payload[key] = tokens; +} + +function responseSchema(body: JsonRecord): unknown { + const format = toRecord(body.response_format); + const jsonSchema = toRecord(format?.json_schema); + return jsonSchema?.schema ?? format?.schema; +} + +function applyModeFields(payload: JsonRecord, body: JsonRecord, mode: ClovaMode): void { + if (mode === "tools") { + payload.tools = toClovaTools(body.tools); + if (body.tool_choice === "none") payload.toolChoice = "none"; + if (body.tool_choice === "auto" || body.tool_choice === "required") { + payload.toolChoice = "auto"; + } + return; + } + if (mode !== "structured") return; + const schema = responseSchema(body); + if (schema && typeof schema === "object") { + payload.responseFormat = { type: "json", schema }; + } else { + delete payload.thinking; + } +} + +function applyPlainOptions( + payload: JsonRecord, + body: JsonRecord, + reasoning: boolean, + mode: ClovaMode +): void { + if (mode === "plain" && !reasoning) { + if (Array.isArray(body.stop) && body.stop.length > 0) { + payload.stop = body.stop.filter((value) => typeof value === "string"); + } else if (typeof body.stop === "string" && body.stop) { + payload.stop = [body.stop]; + } + } + const seed = clampNumeric(body.seed, 0, 4294967295); + if (seed !== null && seed > 0) payload.seed = Math.floor(seed); + if (body.include_ai_filters === true) payload.includeAiFilters = true; +} + +/** Build the CLOVA Studio v3 request body from an OpenAI Chat Completions body. */ +export function buildClovaPayload( + model: string, + body: Record, + stream: boolean, + credentials?: Record | null +): Record { + void stream; + void credentials; + const reasoning = isClovaReasoningModel(model); + const mode = resolveClovaMode(model, body); + const supportsImages = mode === "plain" && isClovaVisionModel(model); + const payload: JsonRecord = { messages: toClovaMessages(body, mode, supportsImages) }; + + applyThinking(payload, body, reasoning, mode); + applySampling(payload, body); + applyOutputCap(payload, body, reasoning, mode); + applyModeFields(payload, body, mode); + applyPlainOptions(payload, body, reasoning, mode); + return payload; +} + +register(FORMATS.OPENAI, FORMATS.CLOVA, buildClovaPayload, null); diff --git a/open-sse/translator/response/clova-to-openai.ts b/open-sse/translator/response/clova-to-openai.ts new file mode 100644 index 0000000000..4d2b9cee54 --- /dev/null +++ b/open-sse/translator/response/clova-to-openai.ts @@ -0,0 +1,354 @@ +/** + * Naver CLOVA Studio "Chat Completions v3" → OpenAI response translator. + * + * CLOVA v3 streams as SSE with **named events**: + * + * ``` + * id: + * event: token + * data: {"message":{"role":"assistant","content":"안"},"finishReason":null,...} + * + * id: + * event: result + * data: {"message":{"role":"assistant","content":"안녕"},"finishReason":"stop", + * "usage":{"promptTokens":20,"completionTokens":5,"totalTokens":25}} + * ``` + * + * Three traps this translator exists to defuse: + * + * 1. **`event: token` carries an incremental delta, but `event: result` repeats + * the COMPLETE text.** Concatenating both duplicates the whole answer at the + * end of the stream, so the result event is treated as a terminal snapshot: + * it contributes `finish_reason` + `usage` only. + * 2. **Function-calling streams deliver arguments as `partialJson` fragments.** + * The first token carries the tool `id` + `name`; every later token carries + * only a JSON fragment (`{`, `"location`, `":`, ` "`, `Se`, `oul`, `"}`), + * which have to be reassembled into OpenAI's `tool_calls[].function.arguments` + * string. The terminal frame repeats the finished call, so — same rule as the + * text snapshot — it is not re-emitted. + * 3. **Failures can arrive as an in-stream payload** whose `status.code` is not + * `20000`, not just as an HTTP error. Those are surfaced through + * `state.upstreamError` so stream.ts fails the request out and combo fallback + * can run, mirroring the Gemini translator. + * + * Docs: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3 + */ +import { register } from "../registry.ts"; +import { FORMATS } from "../formats.ts"; + +/** CLOVA's success status code (a string, not an HTTP number). */ +const CLOVA_STATUS_OK = "20000"; + +type JsonRecord = Record; + +interface ClovaStreamState extends JsonRecord { + responseId?: string; + created?: number; + model?: string; + chunkIndex?: number; + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; + upstreamError?: { status: number; type: string; code: string; message: string }; + toolCallStarted?: boolean; + finishReason?: string; +} + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +/** Map a CLOVA `finishReason` onto the OpenAI vocabulary. */ +function mapFinishReason(reason: unknown): string { + switch (String(reason || "")) { + case "length": + return "length"; + case "tool_calls": + return "tool_calls"; + case "content_filter": + return "content_filter"; + default: + return "stop"; + } +} + +/** + * Map a CLOVA string status code onto an HTTP status for error surfacing. + * Codes are 5-digit strings: `2xxxx` success, `4xxxx` client, `5xxxx` server. + */ +function httpStatusFromClovaCode(code: unknown): number { + const first = String(code || "").charAt(0); + if (first === "4") return 400; + return 502; +} + +/** + * Parse one raw SSE frame into `{ event, data }`. + * CLOVA emits `id:` / `event:` / `data:` lines per frame. + */ +export function parseClovaSseFrame(raw: string): { event: string; data: unknown } | null { + if (typeof raw !== "string" || !raw.trim()) return null; + + let event = ""; + let dataLine = ""; + + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (trimmed.startsWith("event:")) { + event = trimmed.slice(6).trim(); + } else if (trimmed.startsWith("data:")) { + dataLine = trimmed.slice(5).trim(); + } + } + + if (!dataLine) return null; + + try { + return { event, data: JSON.parse(dataLine) }; + } catch { + return null; + } +} + +function baseChunk(state: ClovaStreamState): Record { + return { + id: state.responseId, + object: "chat.completion.chunk", + created: state.created, + model: state.model || "clova", + }; +} + +/** + * Build one OpenAI delta chunk. + * + * `field` selects the delta key: `"content"` for the visible answer and + * `"reasoning_content"` for CLOVA's `thinkingContent` (HCX-007). + */ +function deltaChunk( + state: ClovaStreamState, + content: string, + field = "content" +): Record { + const chunk = baseChunk(state); + chunk.choices = [ + { + index: 0, + delta: { + ...((state.chunkIndex ?? 0) === 0 ? { role: "assistant" } : {}), + [field]: content, + }, + finish_reason: null, + }, + ]; + state.chunkIndex = (state.chunkIndex ?? 0) + 1; + return chunk; +} + +/** First tool-call chunk: carries id + name and opens an empty argument string. */ +function toolCallStartChunk( + state: ClovaStreamState, + id: string, + name: string +): Record { + const chunk = baseChunk(state); + chunk.choices = [ + { + index: 0, + delta: { + ...((state.chunkIndex ?? 0) === 0 ? { role: "assistant" } : {}), + tool_calls: [ + { + index: 0, + id: id || `call_${state.responseId}`, + type: "function", + function: { name, arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ]; + state.chunkIndex = (state.chunkIndex ?? 0) + 1; + return chunk; +} + +/** Subsequent tool-call chunk: appends one `partialJson` fragment. */ +function toolCallArgumentsChunk( + state: ClovaStreamState, + fragment: string +): Record { + const chunk = baseChunk(state); + chunk.choices = [ + { + index: 0, + delta: { tool_calls: [{ index: 0, function: { arguments: fragment } }] }, + finish_reason: null, + }, + ]; + state.chunkIndex = (state.chunkIndex ?? 0) + 1; + return chunk; +} + +function terminalChunk(state: ClovaStreamState, finishReason: string): Record { + const chunk = baseChunk(state); + chunk.choices = [{ index: 0, delta: {}, finish_reason: finishReason }]; + if (state.usage) chunk.usage = state.usage; + return chunk; +} + +function recordUsage(state: ClovaStreamState, usage: unknown): void { + const record = toRecord(usage); + if (!record) return; + const prompt = Number(record.promptTokens) || 0; + const completion = Number(record.completionTokens) || 0; + const total = Number(record.totalTokens) || prompt + completion; + state.usage = { + prompt_tokens: prompt, + completion_tokens: completion, + total_tokens: total, + }; +} + +function recordUpstreamError(state: ClovaStreamState, code: unknown, message: unknown): void { + const status = httpStatusFromClovaCode(code); + state.upstreamError = { + status, + type: status === 429 ? "rate_limit_error" : "server_error", + code: String(code || "clova_error"), + message: typeof message === "string" && message ? message : "CLOVA Studio upstream failure", + }; +} + +interface DecodedClovaChunk { + event: string; + data: JsonRecord; +} + +function initializeState(state: ClovaStreamState): void { + if (state.responseId) return; + state.responseId = `chatcmpl-${Date.now()}`; + state.created = Math.floor(Date.now() / 1000); + state.chunkIndex = 0; +} + +function decodeClovaChunk(chunk: unknown): DecodedClovaChunk | null { + if (typeof chunk === "string") { + const frame = parseClovaSseFrame(chunk); + const data = toRecord(frame?.data); + return frame && data ? { event: frame.event, data } : null; + } + const data = toRecord(chunk); + if (!data) return null; + return { event: String(data.event || data._eventType || ""), data }; +} + +function handleErrorEnvelope(state: ClovaStreamState, event: string, data: JsonRecord): boolean { + const status = toRecord(data.status); + const statusCode = status?.code ?? data.statusCode; + if (statusCode != null && String(statusCode) !== CLOVA_STATUS_OK) { + recordUpstreamError(state, statusCode, status?.message ?? data.message); + return true; + } + + const error = toRecord(data.error); + if (event !== "error" && !error) return false; + const source = error ?? data; + const errorStatus = toRecord(source.status); + recordUpstreamError( + state, + errorStatus?.code ?? source.code, + errorStatus?.message ?? source.message + ); + return true; +} + +function toolCallDelta(state: ClovaStreamState, call: unknown): Record | null { + const record = toRecord(call); + const fn = toRecord(record?.function); + if (!record || !fn) return null; + const id = typeof record.id === "string" ? record.id : ""; + const name = typeof fn.name === "string" ? fn.name : ""; + if (id || name) { + if (state.toolCallStarted) return null; + state.toolCallStarted = true; + return toolCallStartChunk(state, id, name); + } + return typeof fn.partialJson === "string" && fn.partialJson + ? toolCallArgumentsChunk(state, fn.partialJson) + : null; +} + +function toolCallDeltas( + state: ClovaStreamState, + message: JsonRecord +): Record | Array> | null { + if (!Array.isArray(message.toolCalls) || message.toolCalls.length === 0) return null; + const out = message.toolCalls + .map((call) => toolCallDelta(state, call)) + .filter((chunk): chunk is Record => chunk !== null); + if (out.length === 0) return null; + return out.length === 1 ? out[0] : out; +} + +function convertTokenEvent( + state: ClovaStreamState, + data: JsonRecord +): Record | Array> | null { + const message = toRecord(data.message) ?? data; + const toolDeltas = toolCallDeltas(state, message); + if (toolDeltas) return toolDeltas; + const thinking = message.thinkingContent ?? data.thinkingContent; + if (thinking) return deltaChunk(state, String(thinking), "reasoning_content"); + const content = message.content ?? data.content; + return content ? deltaChunk(state, String(content)) : null; +} + +function shouldEmitResultSnapshot( + state: ClovaStreamState, + isResultEvent: boolean, + snapshot: unknown +): snapshot is string { + return ( + !isResultEvent && (state.chunkIndex ?? 0) === 0 && typeof snapshot === "string" && !!snapshot + ); +} + +function convertResultEvent( + state: ClovaStreamState, + event: string, + data: JsonRecord +): Record | Array> | null { + const isResultEvent = event === "result" || event === "stop"; + const resultEnvelope = toRecord(data.result); + if (!isResultEvent && (event || !resultEnvelope)) return null; + + const result = resultEnvelope ?? data; + const message = toRecord(result.message); + recordUsage(state, result.usage); + const hasToolCalls = Array.isArray(message?.toolCalls) && message.toolCalls.length > 0; + const finishReason = hasToolCalls ? "tool_calls" : mapFinishReason(result.finishReason); + state.finishReason = finishReason; + + const snapshot = message?.content ?? result.content; + if (shouldEmitResultSnapshot(state, isResultEvent, snapshot)) { + return [deltaChunk(state, snapshot), terminalChunk(state, finishReason)]; + } + return terminalChunk(state, finishReason); +} + +/** Convert one CLOVA stream frame or JSON envelope into OpenAI chunk(s). */ +export function convertClovaToOpenAI( + chunk: unknown, + state: Record +): Record | Array> | null { + if (chunk == null) return null; + const streamState = state as ClovaStreamState; + initializeState(streamState); + const decoded = decodeClovaChunk(chunk); + if (!decoded) return null; + if (handleErrorEnvelope(streamState, decoded.event, decoded.data)) return null; + return decoded.event === "token" + ? convertTokenEvent(streamState, decoded.data) + : convertResultEvent(streamState, decoded.event, decoded.data); +} + +register(FORMATS.CLOVA, FORMATS.OPENAI, null, convertClovaToOpenAI); diff --git a/scripts/check/check-known-symbols.ts b/scripts/check/check-known-symbols.ts index e655c169bb..769afa5493 100644 --- a/scripts/check/check-known-symbols.ts +++ b/scripts/check/check-known-symbols.ts @@ -211,6 +211,8 @@ export const KNOWN_TRANSLATOR_PAIRS: readonly string[] = [ "antigravity:openai", "claude:gemini", "claude:openai", + // Naver CLOVA Studio Chat Completions v3 (native envelope, model in URL path). + "clova:openai", "cursor:openai", "gemini:claude", "gemini:openai", @@ -218,6 +220,7 @@ export const KNOWN_TRANSLATOR_PAIRS: readonly string[] = [ "openai-responses:openai", "openai:antigravity", "openai:claude", + "openai:clova", "openai:cursor", "openai:gemini", "openai:kiro", diff --git a/src/shared/constants/providers/apikey/regional.ts b/src/shared/constants/providers/apikey/regional.ts index a9c5701dfd..2c437cc4b3 100644 --- a/src/shared/constants/providers/apikey/regional.ts +++ b/src/shared/constants/providers/apikey/regional.ts @@ -494,7 +494,7 @@ export const APIKEY_PROVIDERS_REGIONAL = { textIcon: "CS", website: "https://api.ncloud-docs.com/docs/en/ai-naver-clovastudio-summary", apiHint: - "CLOVA Studio (HyperCLOVA X) is OpenAI-compatible on /v1/openai. OmniRoute probes /v1/openai/models and routes chat traffic to /v1/openai/chat/completions. Uses the current clovastudio.stream.ntruss.com host — the legacy clovastudio.apigw.ntruss.com endpoint is being deprecated.", + "OmniRoute routes chat traffic to the native Chat Completions v3 API (/v3/chat-completions/{model}), not the OpenAI-compatibility shim. All three v3 models are served: HCX-007 (reasoning, text only), HCX-005 (vision — accepts both public image URLs and inline base64 images), and HCX-DASH-002 (lightweight, text only). Requests stream upstream and are accumulated into a JSON body when the client asks for a non-streaming response.", }, internlm: { id: "internlm", diff --git a/src/shared/constants/visionModels.ts b/src/shared/constants/visionModels.ts index da7b755490..bcad514058 100644 --- a/src/shared/constants/visionModels.ts +++ b/src/shared/constants/visionModels.ts @@ -57,6 +57,11 @@ export const VISION_MODEL_ID_FRAGMENTS = [ "mistral-medium-3", "minimax-m3", "kimi-k2.", + // Naver CLOVA Studio: HCX-005 is the only v3 model with image input. Listed by + // exact id (not a family fragment) to stay conservative — live-verified on + // 2026-09-01 that it answers image prompts over both a public URL and a + // base64 data URI, while HCX-007 and HCX-DASH-002 reject images. + "hcx-005", "-vision", "multimodal", ] as const; diff --git a/tests/snapshots/executors/executor-map.json b/tests/snapshots/executors/executor-map.json index 94d70fb770..8f4956e814 100644 --- a/tests/snapshots/executors/executor-map.json +++ b/tests/snapshots/executors/executor-map.json @@ -135,6 +135,11 @@ "configSource": "", "provider": "cloudflare-playground" }, + "clova-studio": { + "className": "ClovaStudioExecutor", + "configSource": "clova-studio", + "provider": "clova-studio" + }, "cmd": { "className": "CommandCodeExecutor", "configSource": "", @@ -671,6 +676,6 @@ "provider": "zai-web" } }, - "keyCount": 134, + "keyCount": 135, "sharedInstances": [] } diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 43150e2b22..64fd60cfa2 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -1217,7 +1217,7 @@ } }, "clova-studio": { - "format": "openai", + "format": "clova", "headers": { "apiKey": { "Accept": "text/event-stream", @@ -1235,8 +1235,8 @@ } }, "url": { - "nonStream": "https://clovastudio.stream.ntruss.com/v1/openai/chat/completions", - "stream": "https://clovastudio.stream.ntruss.com/v1/openai/chat/completions" + "nonStream": "https://clovastudio.stream.ntruss.com/v3/chat-completions", + "stream": "https://clovastudio.stream.ntruss.com/v3/chat-completions" } }, "codebuddy-cn": { diff --git a/tests/unit/embedding-clova-v2.test.ts b/tests/unit/embedding-clova-v2.test.ts new file mode 100644 index 0000000000..9a97a1c5ee --- /dev/null +++ b/tests/unit/embedding-clova-v2.test.ts @@ -0,0 +1,258 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Naver CLOVA Studio embedding v2. +// +// The endpoint embeds exactly ONE text per request (`{"text": …}` → one vector) +// and answers `{status, result:{embedding:[…1024 floats], inputTokens}}`, so a +// batched `/v1/embeddings` call has to be fanned out into N upstream calls and +// merged back into OpenAI's list shape. +// +// Live-verified against the API on 2026-09-01: 1024 dimensions, ~100ms per call, +// and an empty string is rejected with `40004 Text empty`. + +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-clova-embeddings-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.SQLITE_FILE = join(TEST_DATA_DIR, "storage.sqlite"); + +const registry = await import("../../open-sse/config/embeddingRegistry.ts"); +const { normalizeClovaEmbeddingV2Response } = + await import("../../open-sse/handlers/embeddingStructuredInput.ts"); +const { handleEmbedding } = await import("../../open-sse/handlers/embeddings.ts"); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + +test.after(async () => { + // handleEmbedding records call logs asynchronously; let those writes settle + // before closing the singleton so a late write cannot reopen the test DB. + await new Promise((resolve) => setTimeout(resolve, 50)); + resetDbInstance(); +}); + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +test("clova embedding v2 is registered with the single-text protocol", () => { + const provider = registry.getEmbeddingProvider("clova-studio"); + assert.ok(provider, "clova-studio must be an embedding provider"); + assert.equal(provider.baseUrl, "https://clovastudio.stream.ntruss.com/v1/api-tools/embedding/v2"); + assert.equal(provider.singleTextProtocol, "clova-v2"); + assert.equal(provider.authType, "apikey"); + assert.equal(provider.authHeader, "bearer"); + assert.deepEqual(provider.models, [ + { id: "clova-embedding-v2", name: "CLOVA Embedding v2", dimensions: 1024 }, + ]); +}); + +test("clova embedding v2 resolves its model and dimension", () => { + assert.deepEqual(registry.parseEmbeddingModel("clova-studio/clova-embedding-v2"), { + provider: "clova-studio", + model: "clova-embedding-v2", + }); + assert.equal(registry.getEmbeddingDimension("clova-studio/clova-embedding-v2"), 1024); +}); + +// --------------------------------------------------------------------------- +// Response normalisation +// --------------------------------------------------------------------------- + +test("a success envelope is normalised into OpenAI list shape", () => { + const normalized = normalizeClovaEmbeddingV2Response({ + status: { code: "20000", message: "OK" }, + result: { embedding: [0.1, -0.2, 0.3], inputTokens: 4 }, + }); + assert.deepEqual(normalized, { + data: [{ object: "embedding", index: 0, embedding: [0.1, -0.2, 0.3] }], + usage: { prompt_tokens: 4, total_tokens: 4 }, + }); +}); + +test("a failure envelope is rejected instead of becoming an empty success", () => { + assert.throws( + () => + normalizeClovaEmbeddingV2Response({ + status: { code: "40004", message: "Text empty" }, + }), + /unsuccessful status/ + ); +}); + +test("a payload without an embedding vector is rejected", () => { + assert.throws( + () => + normalizeClovaEmbeddingV2Response({ + status: { code: "20000" }, + result: { inputTokens: 0 }, + }), + /missing an embedding vector/ + ); +}); + +// --------------------------------------------------------------------------- +// Batch fan-out through the real handler +// --------------------------------------------------------------------------- + +const originalFetch = globalThis.fetch; + +function mockClova(calls: Array>): void { + globalThis.fetch = (async (_url: string, init: RequestInit) => { + calls.push(JSON.parse(String(init.body))); + const text = String((calls[calls.length - 1] as { text?: string }).text ?? ""); + return new Response( + JSON.stringify({ + status: { code: "20000", message: "OK" }, + result: { embedding: [text.length, 1, 2], inputTokens: text.length }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; +} + +function mockClovaEnvelope( + calls: Array>, + envelope: Record +): void { + globalThis.fetch = (async (_url: string, init: RequestInit) => { + calls.push(JSON.parse(String(init.body))); + return new Response(JSON.stringify(envelope), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; +} + +test("a batched input is fanned out into one upstream call per text", async () => { + const calls: Array> = []; + mockClova(calls); + try { + const result = await handleEmbedding({ + body: { model: "clova-studio/clova-embedding-v2", input: ["alpha", "beta", "gamma"] }, + credentials: { apiKey: "test-key" }, + }); + + assert.equal(result.success, true, JSON.stringify(result)); + // One request per text — the endpoint cannot batch. + assert.deepEqual( + calls.map((c) => c.text), + ["alpha", "beta", "gamma"] + ); + + const data = (result as { data: Record }).data; + assert.equal(data.object, "list"); + assert.equal(data.model, "clova-studio/clova-embedding-v2"); + assert.equal((data.data as unknown[]).length, 3); + // Indexes must reflect the caller's positions, not each upstream call's 0. + assert.deepEqual( + (data.data as Array<{ index: number }>).map((d) => d.index), + [0, 1, 2] + ); + // Token usage is summed across the fan-out. + assert.equal((data.usage as { prompt_tokens: number }).prompt_tokens, 5 + 4 + 5); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("an empty text rejects the batch without changing response indexes", async () => { + const calls: Array> = []; + mockClova(calls); + try { + const result = await handleEmbedding({ + body: { model: "clova-studio/clova-embedding-v2", input: ["", " ", "real"] }, + credentials: { apiKey: "test-key" }, + }); + assert.equal(result.success, false); + assert.equal((result as { status: number }).status, 400); + assert.equal(calls.length, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("an all-empty input fails without calling upstream", async () => { + const calls: Array> = []; + mockClova(calls); + try { + const result = await handleEmbedding({ + body: { model: "clova-studio/clova-embedding-v2", input: ["", ""] }, + credentials: { apiKey: "test-key" }, + }); + assert.equal(result.success, false); + assert.equal((result as { status: number }).status, 400); + assert.equal(calls.length, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("a single string input takes the fan-out path too", async () => { + const calls: Array> = []; + mockClova(calls); + try { + const result = await handleEmbedding({ + body: { model: "clova-studio/clova-embedding-v2", input: "solo" }, + credentials: { apiKey: "test-key" }, + }); + assert.equal(result.success, true, JSON.stringify(result)); + assert.deepEqual( + calls.map((c) => c.text), + ["solo"] + ); + assert.equal(((result as { data: Record }).data.data as unknown[]).length, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("an HTTP-200 CLOVA error envelope becomes a provider failure", async () => { + const calls: Array> = []; + mockClovaEnvelope(calls, { status: { code: "40004", message: "Text empty" } }); + try { + const result = await handleEmbedding({ + body: { model: "clova-studio/clova-embedding-v2", input: "text" }, + credentials: { apiKey: "test-key" }, + }); + assert.equal(result.success, false); + assert.equal((result as { status: number }).status, 502); + assert.equal(calls.length, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("token-array input is rejected instead of being silently dropped", async () => { + const calls: Array> = []; + mockClova(calls); + try { + const result = await handleEmbedding({ + body: { model: "clova-studio/clova-embedding-v2", input: [101, 202] }, + credentials: { apiKey: "test-key" }, + }); + assert.equal(result.success, false); + assert.equal((result as { status: number }).status, 400); + assert.equal(calls.length, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("unsupported output options are rejected instead of ignored", async () => { + for (const extra of [{ encoding_format: "base64" }, { dimensions: 1536 }]) { + const calls: Array> = []; + mockClova(calls); + try { + const result = await handleEmbedding({ + body: { model: "clova-studio/clova-embedding-v2", input: "text", ...extra }, + credentials: { apiKey: "test-key" }, + }); + assert.equal(result.success, false); + assert.equal((result as { status: number }).status, 400); + assert.equal(calls.length, 0); + } finally { + globalThis.fetch = originalFetch; + } + } +}); diff --git a/tests/unit/translator-clova-v3.test.ts b/tests/unit/translator-clova-v3.test.ts new file mode 100644 index 0000000000..fb9851f7d3 --- /dev/null +++ b/tests/unit/translator-clova-v3.test.ts @@ -0,0 +1,825 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Naver CLOVA Studio "Chat Completions v3" translator pair. +// +// The guard that matters most here is the stream-duplication case: `event: token` +// carries an INCREMENTAL delta while the terminal `event: result` repeats the +// COMPLETE text. Concatenating both doubles the whole answer at the end of the +// stream, so the result event must contribute finish_reason + usage only. +// +// Wire docs: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3 + +const request = await import("../../open-sse/translator/request/openai-to-clova.ts"); +const response = await import("../../open-sse/translator/response/clova-to-openai.ts"); +const registry = await import("../../open-sse/translator/registry.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); +const { getExecutor } = await import("../../open-sse/executors/index.ts"); + +// --------------------------------------------------------------------------- +// Request: OpenAI → CLOVA v3 +// --------------------------------------------------------------------------- + +test("clova v3: registers the request and response translator pair", () => { + assert.ok(registry.getRequestTranslator(FORMATS.OPENAI, FORMATS.CLOVA)); + assert.ok(registry.getResponseTranslator(FORMATS.CLOVA, FORMATS.OPENAI)); +}); + +test("clova v3: its executor appends and URL-encodes the selected model", async () => { + const executor = await getExecutor("clova-studio"); + assert.equal( + executor.buildUrl("HCX 005", true), + "https://clovastudio.stream.ntruss.com/v3/chat-completions/HCX%20005" + ); +}); + +test("clova v3: string content becomes a typed text part", () => { + const body = { messages: [{ role: "user", content: "hello" }] }; + const payload = request.buildClovaPayload("HCX-005", body, true, null); + assert.deepEqual(payload.messages[0], { + role: "user", + content: [{ type: "text", text: "hello" }], + }); +}); + +test("clova v3: sampling params are camelCased", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "hi" }], + max_tokens: 512, + top_p: 0.8, + top_k: 4, + temperature: 0.5, + repetition_penalty: 1.15, + seed: 42, + stop: ["END"], + }, + true, + null + ); + assert.equal(payload.maxTokens, 512); + assert.equal(payload.topP, 0.8); + assert.equal(payload.topK, 4); + assert.equal(payload.temperature, 0.5); + assert.equal(payload.repetitionPenalty, 1.15); + assert.equal(payload.seed, 42); + assert.deepEqual(payload.stop, ["END"]); + // snake_case must not leak upstream. + assert.equal(payload.max_tokens, undefined); + assert.equal(payload.top_p, undefined); +}); + +test("clova v3: output tokens are clamped to the documented 4096 cap", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { messages: [{ role: "user", content: "hi" }], max_tokens: 100000 }, + true, + null + ); + assert.equal(payload.maxTokens, request.CLOVA_V3_MAX_OUTPUT_TOKENS); +}); + +test("clova v3: max_completion_tokens on a text model still maps to maxTokens", () => { + // Only reasoning models speak `maxCompletionTokens`; for text models the cap is + // `maxTokens` regardless of which OpenAI alias the client used. + const payload = request.buildClovaPayload( + "HCX-005", + { messages: [{ role: "user", content: "hi" }], max_completion_tokens: 1024 }, + true, + null + ); + assert.equal(payload.maxTokens, 1024); + assert.equal(payload.maxCompletionTokens, undefined); +}); + +test("clova v3: model and stream are not sent in the body", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { model: "HCX-005", stream: true, messages: [{ role: "user", content: "hi" }] }, + true, + null + ); + // The model travels in the URL path and streaming is driven by Accept. + assert.equal(payload.model, undefined); + assert.equal(payload.stream, undefined); +}); + +// --------------------------------------------------------------------------- +// Function calling (v3-fc) — same endpoint, different body fields +// --------------------------------------------------------------------------- + +test("clova v3: tools are translated and toolChoice auto is forwarded", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "Weather in Seoul?" }], + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the weather for a city", + parameters: { + type: "object", + properties: { location: { type: "string" } }, + required: ["location"], + }, + }, + }, + ], + tool_choice: "auto", + }, + true, + null + ); + assert.deepEqual(payload.tools, [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the weather for a city", + parameters: { + type: "object", + properties: { location: { type: "string" } }, + required: ["location"], + }, + }, + }, + ]); + assert.equal(payload.toolChoice, "auto"); +}); + +test("clova v3: toolChoice none is forwarded; a forced choice is dropped", () => { + // Live-verified: `toolChoice: {type:"function", function:{name}}` returns + // `40009 Unsupported function` — CLOVA only accepts "auto" and "none". + const none = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f" } }], + tool_choice: "none", + }, + true, + null + ); + assert.equal(none.toolChoice, "none"); + + const forced = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f" } }], + tool_choice: { type: "function", function: { name: "f" } }, + }, + true, + null + ); + assert.equal(forced.toolChoice, undefined); +}); + +test("clova v3: function calling raises the cap to the documented 1024 minimum", () => { + // Live-verified: any cap below 1024 fails with + // `40001 Invalid parameter: tools, maxTokens`. + const below = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f" } }], + max_tokens: 128, + }, + true, + null + ); + assert.equal(below.maxTokens, request.CLOVA_V3_MIN_TOOL_TOKENS); + + const absent = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f" } }], + }, + true, + null + ); + assert.equal(absent.maxTokens, request.CLOVA_V3_MIN_TOOL_TOKENS); + + const above = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f" } }], + max_tokens: 2048, + }, + true, + null + ); + assert.equal(above.maxTokens, 2048); +}); + +test("clova v3: function calling forces thinking.effort none on the reasoning model", () => { + // Live-verified: HCX-007 without it returns + // `40001 Invalid parameter: tools, thinking`. + const payload = request.buildClovaPayload( + "HCX-007", + { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f" } }], + max_tokens: 2048, + }, + true, + null + ); + assert.deepEqual(payload.thinking, { effort: "none" }); + assert.equal(payload.maxCompletionTokens, 2048); + assert.equal(payload.maxTokens, undefined); +}); + +test("clova v3: non-reasoning models never receive a thinking field", () => { + // Regression guard: HCX-005 and HCX-DASH-002 reject `thinking` outright + // (live-verified: `40001 Invalid parameter: thinking`), even with tools. + for (const model of ["HCX-005", "HCX-DASH-002"]) { + const withTools = request.buildClovaPayload( + model, + { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f" } }], + }, + true, + null + ); + assert.equal(withTools.thinking, undefined, `${model} must not receive thinking`); + assert.ok(Array.isArray(withTools.tools)); + + const askingForReasoning = request.buildClovaPayload( + model, + { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, + true, + null + ); + assert.equal(askingForReasoning.thinking, undefined, `${model} ignores reasoning_effort`); + } +}); + +test("clova v3: images are dropped in function-calling mode", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "describe" }, + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + ], + }, + ], + tools: [{ type: "function", function: { name: "f" } }], + }, + true, + null + ); + assert.deepEqual(payload.messages[0], { role: "user", content: "describe" }); + assert.ok(!JSON.stringify(payload).includes("imageUrl")); +}); + +test("clova v3: a tool result round-trips as role tool with toolCallId", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [ + { role: "user", content: "Weather in Seoul?" }, + { + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_abc", + type: "function", + function: { name: "get_weather", arguments: '{"location":"Seoul"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_abc", content: '{"temp":17}' }, + ], + tools: [{ type: "function", function: { name: "get_weather" } }], + }, + true, + null + ); + + assert.deepEqual(payload.messages[1], { + role: "assistant", + content: "", + toolCalls: [ + { + id: "call_abc", + type: "function", + function: { name: "get_weather", arguments: { location: "Seoul" } }, + }, + ], + }); + // CLOVA wants `arguments` as an object; OpenAI sends a JSON string. + assert.equal(typeof payload.messages[1].toolCalls[0].function.arguments, "object"); + + assert.deepEqual(payload.messages[2], { + role: "tool", + content: '{"temp":17}', + toolCallId: "call_abc", + }); +}); + +// --------------------------------------------------------------------------- +// Structured Outputs (v3-so) — HCX-007 only +// --------------------------------------------------------------------------- + +test("clova v3: json_schema maps onto responseFormat", () => { + const schema = { + type: "object", + properties: { temp_high_c: { type: "number" } }, + required: ["temp_high_c"], + }; + const payload = request.buildClovaPayload( + "HCX-007", + { + messages: [{ role: "user", content: "..." }], + response_format: { type: "json_schema", json_schema: { name: "weather", schema } }, + }, + true, + null + ); + assert.deepEqual(payload.responseFormat, { type: "json", schema }); + // Structured Outputs cannot be combined with reasoning (live-verified). + assert.deepEqual(payload.thinking, { effort: "none" }); +}); + +test("clova v3: structured outputs are dropped off the HCX-007-only path", () => { + // HCX-005 rejects `thinking` outright, so SO is unavailable there + // (live-verified: `40001 Invalid parameter: thinking`). + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "..." }], + response_format: { + type: "json_schema", + json_schema: { name: "x", schema: { type: "object" } }, + }, + }, + true, + null + ); + assert.equal(payload.responseFormat, undefined); + assert.equal(payload.thinking, undefined); +}); + +test("clova v3: function calling wins over structured outputs", () => { + const payload = request.buildClovaPayload( + "HCX-007", + { + messages: [{ role: "user", content: "..." }], + tools: [{ type: "function", function: { name: "f" } }], + response_format: { + type: "json_schema", + json_schema: { name: "x", schema: { type: "object" } }, + }, + }, + true, + null + ); + assert.ok(Array.isArray(payload.tools)); + assert.equal(payload.responseFormat, undefined); +}); + +test("clova v3: a public image URL maps to imageUrl.url", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "describe" }, + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + ], + }, + ], + }, + true, + null + ); + const parts = payload.messages[0].content; + assert.deepEqual(parts[1], { + type: "image_url", + imageUrl: { url: "https://example.com/a.png" }, + }); +}); + +test("clova v3: base64 images keep their full data-URI prefix in dataUri.data", () => { + // Regression guard: the prefix MUST survive. Sending only the base64 payload + // (prefix stripped) makes CLOVA reject the whole request with + // `40001 Invalid parameter`, while the complete data-URI string is accepted. + // Live-verified 2026-09-01 with PNG and JPEG at 16x16, 64x64 and full size. + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: "data:image/png;base64,AAAABBBB" } }, + { type: "text", text: "what is this" }, + ], + }, + ], + }, + true, + null + ); + assert.deepEqual(payload.messages[0].content[0], { + type: "image_url", + dataUri: { data: "data:image/png;base64,AAAABBBB" }, + }); + assert.deepEqual(payload.messages[0].content[1], { type: "text", text: "what is this" }); +}); + +test("clova v3: a data: image never leaks into imageUrl.url", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "data:image/jpeg;base64,ZZZZ" } }], + }, + ], + }, + true, + null + ); + const part = payload.messages[0].content[0]; + assert.equal(part.imageUrl, undefined); + assert.deepEqual(part.dataUri, { data: "data:image/jpeg;base64,ZZZZ" }); +}); + +test("clova v3: images are stripped for a text-only model", () => { + const payload = request.buildClovaPayload( + "HCX-DASH-002", + { + messages: [ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + { type: "text", text: "describe" }, + ], + }, + ], + }, + true, + null + ); + assert.deepEqual(payload.messages[0].content, [{ type: "text", text: "describe" }]); +}); + +// --------------------------------------------------------------------------- +// Reasoning model (HCX-007) contract +// --------------------------------------------------------------------------- + +test("clova v3: reasoning models use maxCompletionTokens, never maxTokens", () => { + // Live-verified: HCX-007 answers 40001 "Invalid parameter: maxTokens" when the + // cap is sent as `maxTokens`, and succeeds with `maxCompletionTokens`. + const withMaxTokens = request.buildClovaPayload( + "HCX-007", + { messages: [{ role: "user", content: "hi" }], max_tokens: 1024 }, + true, + null + ); + assert.equal(withMaxTokens.maxCompletionTokens, 1024); + assert.equal(withMaxTokens.maxTokens, undefined); + + const withMaxCompletion = request.buildClovaPayload( + "HCX-007", + { messages: [{ role: "user", content: "hi" }], max_completion_tokens: 2048 }, + true, + null + ); + assert.equal(withMaxCompletion.maxCompletionTokens, 2048); +}); + +test("clova v3: reasoning output cap is 32768, not the 4096 text-model cap", () => { + const payload = request.buildClovaPayload( + "HCX-007", + { messages: [{ role: "user", content: "hi" }], max_tokens: 999999 }, + true, + null + ); + assert.equal(payload.maxCompletionTokens, request.CLOVA_V3_REASONING_MAX_OUTPUT_TOKENS); + + const textModel = request.buildClovaPayload( + "HCX-005", + { messages: [{ role: "user", content: "hi" }], max_tokens: 999999 }, + true, + null + ); + assert.equal(textModel.maxTokens, request.CLOVA_V3_MAX_OUTPUT_TOKENS); +}); + +test("clova v3: stop is dropped for reasoning models", () => { + // The vendor docs state `stop` cannot be used while thinking. + const reasoning = request.buildClovaPayload( + "HCX-007", + { messages: [{ role: "user", content: "hi" }], stop: ["END"] }, + true, + null + ); + assert.equal(reasoning.stop, undefined); + + const text = request.buildClovaPayload( + "HCX-005", + { messages: [{ role: "user", content: "hi" }], stop: ["END"] }, + true, + null + ); + assert.deepEqual(text.stop, ["END"]); +}); + +test("clova v3: images are stripped for the reasoning model (HCX-007 has no vision)", () => { + const payload = request.buildClovaPayload( + "HCX-007", + { + messages: [ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + { type: "text", text: "describe" }, + ], + }, + ], + }, + true, + null + ); + assert.deepEqual(payload.messages[0].content, [{ type: "text", text: "describe" }]); +}); + +test("clova v3: reasoning_effort maps onto thinking.effort", () => { + assert.equal(request.toClovaThinkingEffort("low"), "low"); + assert.equal(request.toClovaThinkingEffort("high"), "high"); + // OpenAI's `minimal` has no CLOVA equivalent; `low` is the closest. + assert.equal(request.toClovaThinkingEffort("minimal"), "low"); + // Unknown values are omitted so CLOVA applies its own default. + assert.equal(request.toClovaThinkingEffort("bogus"), ""); + + const payload = request.buildClovaPayload( + "HCX-007", + { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, + true, + null + ); + assert.deepEqual(payload.thinking, { effort: "high" }); + + const noEffort = request.buildClovaPayload( + "HCX-007", + { messages: [{ role: "user", content: "hi" }] }, + true, + null + ); + assert.equal(noEffort.thinking, undefined); + + // Non-reasoning models must never receive the thinking envelope. + const textModel = request.buildClovaPayload( + "HCX-005", + { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, + true, + null + ); + assert.equal(textModel.thinking, undefined); +}); + +// --------------------------------------------------------------------------- +// Response: CLOVA v3 → OpenAI +// --------------------------------------------------------------------------- + +function tokenFrame(text: string): string { + return ( + `id: aabb\n` + + `event: token\n` + + `data: ${JSON.stringify({ message: { role: "assistant", content: text }, finishReason: null, created: 1 })}\n\n` + ); +} + +function resultFrame(fullText: string): string { + return ( + `id: aabb\n` + + `event: result\n` + + `data: ${JSON.stringify({ + message: { role: "assistant", content: fullText }, + finishReason: "stop", + created: 1, + usage: { promptTokens: 20, completionTokens: 5, totalTokens: 25 }, + })}\n\n` + ); +} + +test("clova v3: a token frame emits an incremental delta", () => { + const state = {}; + const chunk = response.convertClovaToOpenAI(tokenFrame("안"), state); + assert.equal(chunk.choices[0].delta.content, "안"); + // First chunk carries the assistant role, per OpenAI semantics. + assert.equal(chunk.choices[0].delta.role, "assistant"); + assert.equal(chunk.choices[0].finish_reason, null); +}); + +test("clova v3: the result frame does NOT repeat the already-streamed text", () => { + const state = {}; + response.convertClovaToOpenAI(tokenFrame("안"), state); + response.convertClovaToOpenAI(tokenFrame("녕"), state); + const terminal = response.convertClovaToOpenAI(resultFrame("안녕"), state); + + // The snapshot text must not be re-emitted — this is the duplication guard. + assert.equal(terminal.choices[0].delta.content, undefined); + assert.deepEqual(terminal.choices[0].delta, {}); + assert.equal(terminal.choices[0].finish_reason, "stop"); +}); + +test("clova v3: a full token→result stream yields the answer exactly once", () => { + const state = {}; + const frames = [tokenFrame("안"), tokenFrame("녕"), resultFrame("안녕")]; + const text = frames + .map((frame) => response.convertClovaToOpenAI(frame, state)) + .filter(Boolean) + .map((chunk) => chunk.choices?.[0]?.delta?.content ?? "") + .join(""); + + assert.equal(text, "안녕"); + assert.notEqual(text, "안녕안녕"); + assert.deepEqual(state.usage, { + prompt_tokens: 20, + completion_tokens: 5, + total_tokens: 25, + }); +}); + +test("clova v3: an upstream status failure surfaces as state.upstreamError", () => { + const state = {}; + const frame = + `id: aabb\n` + + `event: error\n` + + `data: ${JSON.stringify({ status: { code: "40100", message: "Invalid API key" } })}\n\n`; + + assert.equal(response.convertClovaToOpenAI(frame, state), null); + assert.equal(state.upstreamError.status, 400); + assert.match(state.upstreamError.message, /Invalid API key/); +}); + +test("clova v3: a 5xxxx status maps to a 502 upstream error", () => { + const state = {}; + const payload = { + status: { code: "50000", message: "Internal Server Error" }, + result: null, + }; + assert.equal(response.convertClovaToOpenAI(payload, state), null); + assert.equal(state.upstreamError.status, 502); +}); + +test("clova v3: a non-stream envelope replays its text once, then terminates", () => { + const state = {}; + const out = response.convertClovaToOpenAI( + { + status: { code: "20000", message: "OK" }, + result: { + message: { role: "assistant", content: "hello" }, + usage: { promptTokens: 1, completionTokens: 2, totalTokens: 3 }, + finishReason: "stop", + }, + }, + state + ); + + assert.ok(Array.isArray(out)); + assert.equal(out[0].choices[0].delta.content, "hello"); + assert.equal(out[1].choices[0].finish_reason, "stop"); + assert.equal(state.usage.total_tokens, 3); +}); + +test("clova v3: thinkingContent is emitted as reasoning_content", () => { + const state = {}; + const frame = + `id: aabb\n` + + `event: token\n` + + `data: ${JSON.stringify({ message: { role: "assistant", thinkingContent: "생각" }, finishReason: null })}\n\n`; + + const chunk = response.convertClovaToOpenAI(frame, state); + assert.equal(chunk.choices[0].delta.reasoning_content, "생각"); + assert.equal(chunk.choices[0].delta.content, undefined); +}); + +test("clova v3: reasoning and answer deltas stay on separate delta keys", () => { + const state = {}; + const thinking = response.convertClovaToOpenAI( + `event: token\ndata: ${JSON.stringify({ message: { thinkingContent: "because" } })}\n\n`, + state + ); + const answer = response.convertClovaToOpenAI( + `event: token\ndata: ${JSON.stringify({ message: { content: "391" } })}\n\n`, + state + ); + + assert.equal(thinking.choices[0].delta.reasoning_content, "because"); + assert.equal(answer.choices[0].delta.content, "391"); + assert.equal(answer.choices[0].delta.reasoning_content, undefined); +}); + +test("clova v3: a tool-call stream assembles partialJson fragments", () => { + const state = {}; + const frame = (data: unknown, event = "token") => + `id: x\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + + // First frame carries id + name; the rest carry only JSON fragments. + const start = response.convertClovaToOpenAI( + frame({ + message: { + role: "assistant", + content: "", + toolCalls: [{ id: "call_abc", type: "function", function: { name: "get_weather" } }], + }, + finishReason: null, + }), + state + ); + assert.deepEqual(start.choices[0].delta.tool_calls, [ + { + index: 0, + id: "call_abc", + type: "function", + function: { name: "get_weather", arguments: "" }, + }, + ]); + + // Fragment order is taken verbatim from a live HCX-005 function-calling + // stream — note the space after the colon, which CLOVA emits as its own chunk. + let args = ""; + for (const fragment of ['{"', "location", '":', ' "', "Se", "oul", '"}']) { + const chunk = response.convertClovaToOpenAI( + frame({ + message: { + role: "assistant", + content: "", + toolCalls: [{ type: "function", function: { partialJson: fragment } }], + }, + finishReason: null, + }), + state + ); + args += chunk.choices[0].delta.tool_calls[0].function.arguments; + } + assert.equal(args, '{"location": "Seoul"}'); + assert.deepEqual(JSON.parse(args), { location: "Seoul" }); +}); + +test("clova v3: the terminal frame reports tool_calls without repeating the call", () => { + const state = {}; + response.convertClovaToOpenAI( + `id: x\nevent: token\ndata: ${JSON.stringify({ message: { content: "", toolCalls: [{ id: "call_abc", type: "function", function: { name: "get_weather" } }] }, finishReason: null })}\n\n`, + state + ); + + const terminal = response.convertClovaToOpenAI( + `id: x\nevent: result\ndata: ${JSON.stringify({ + message: { + role: "assistant", + content: "", + toolCalls: [ + { + id: "call_abc", + type: "function", + function: { name: "get_weather", arguments: { location: "Seoul" } }, + }, + ], + }, + finishReason: "tool_calls", + usage: { promptTokens: 9, completionTokens: 47, totalTokens: 56 }, + })}\n\n`, + state + ); + + // The finished call is a snapshot — it must not be emitted a second time. + assert.equal(terminal.choices[0].delta.tool_calls, undefined); + assert.deepEqual(terminal.choices[0].delta, {}); + assert.equal(terminal.choices[0].finish_reason, "tool_calls"); + assert.equal(terminal.usage.total_tokens, 56); +}); + +test("clova v3: the flush signal and unparseable frames return null", () => { + const state = {}; + assert.equal(response.convertClovaToOpenAI(null, state), null); + assert.equal(response.convertClovaToOpenAI("id: aabb\nevent: ping\ndata: \n\n", state), null); + assert.equal(response.convertClovaToOpenAI("not json at all", state), null); +}); + +test("clova v3: an unknown event type is ignored", () => { + const state = {}; + const frame = `event: signal\ndata: ${JSON.stringify({ data: "keepalive" })}\n\n`; + assert.equal(response.convertClovaToOpenAI(frame, state), null); +}); From 6795783228ca030f96cdb6ca596abe70f2f9a734 Mon Sep 17 00:00:00 2001 From: backryun Date: Thu, 3 Sep 2026 19:50:41 +0900 Subject: [PATCH 12/47] feat(providers): refresh Fable, Cursor, and Devin catalogs (#12367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com #12524, #12538, #12277 e #12367 sobre o tip de release/v3.8.51: os quatro boardaram sem conflito (áreas disjuntas — zai-web, nvidia, clova, cursor/devin/fable). typecheck:core limpo, check:provider-consistency OK (272 entradas REGISTRY, 355 providers canônicos), check:known-symbols OK, e 305/305 nos testes tocados pelos quatro PRs. Os IDs de modelo adicionados foram conferidos individualmente. Obrigado, @backryun. --- config/quality/eslint-suppressions.json | 24 +- .../config/claudeCodeCompatibleIdentity.ts | 8 +- open-sse/config/context1m.ts | 7 +- .../providers/registry/anthropic/index.ts | 11 + .../providers/registry/bedrock/index.ts | 11 + .../config/providers/registry/claude/index.ts | 11 + .../providers/registry/claude/web/index.ts | 11 + .../config/providers/registry/cursor/index.ts | 488 +++++++------ .../providers/registry/devin/catalog.ts | 257 ++++--- .../config/providers/registry/vertex/index.ts | 1 + .../registry/vertex/partner/index.ts | 1 + .../__tests__/scoresAs-11489.test.ts | 8 +- open-sse/services/modelFamilyFallback.ts | 5 +- open-sse/services/providerCostData.ts | 23 +- open-sse/utils/cursorAgentProtobuf.ts | 659 +++++++++--------- .../requestedModelParameters.ts | 113 +++ open-sse/utils/registeredEffortVariants.ts | 15 +- src/lib/db/models/activeSyncedCatalog.ts | 32 +- src/lib/providerModels/cursorAutoCatalog.ts | 49 +- .../providerModels/cursorAvailableModels.ts | 133 ++-- src/lib/providers/staticModels.ts | 1 + src/shared/constants/cliTools.ts | 2 +- src/shared/constants/modelSpecs.ts | 55 +- .../constants/pricing/default-pricing.ts | 2 + src/shared/constants/pricing/devin.ts | 142 ++++ src/shared/constants/pricing/frontier-labs.ts | 2 + .../constants/pricing/oauth-subscriptions.ts | 2 + src/shared/constants/pricing/shared-tiers.ts | 8 + tests/unit/claude-fable-5-1.test.ts | 172 +++++ .../claude-web-sonnet5-registry-6209.test.ts | 1 + tests/unit/cursor-auto-catalog-entry.test.ts | 27 + tests/unit/cursor-available-models.test.ts | 2 + .../unit/cursor-catalog-combo-compat.test.ts | 4 +- .../cursor-model-effort-suffix-7289.test.ts | 32 + .../cursor-registry-claude-families.test.ts | 209 +++++- tests/unit/devin-cli-catalog.test.ts | 134 +++- .../fixtures/cursor-rewrite-failure-ids.ts | 7 +- .../guardrails/visionBridgeRouter.test.ts | 6 +- ...-model-catalog-reconciliation-8926.test.ts | 16 +- tests/unit/pricing-constants-split.test.ts | 5 +- tests/unit/provider-cost-data.test.ts | 48 ++ tests/unit/provider-models-config.test.ts | 12 +- 42 files changed, 1835 insertions(+), 921 deletions(-) create mode 100644 open-sse/utils/cursorAgentProtobuf/requestedModelParameters.ts create mode 100644 src/shared/constants/pricing/devin.ts create mode 100644 tests/unit/claude-fable-5-1.test.ts create mode 100644 tests/unit/provider-cost-data.test.ts diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 90f949af47..e10d1f6551 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -220,11 +220,6 @@ "count": 26 } }, - "open-sse/handlers/chatCore/clientUsageBuffer.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "open-sse/handlers/chatCore/executorHelpers.ts": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -608,11 +603,6 @@ "count": 1 } }, - "open-sse/services/providerCostData.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "open-sse/services/rateLimitManager.ts": { "@typescript-eslint/no-unused-vars": { "count": 2 @@ -763,7 +753,7 @@ }, "open-sse/utils/cursorAgentProtobuf.ts": { "@typescript-eslint/no-unused-vars": { - "count": 3 + "count": 2 } }, "open-sse/utils/earlyStreamKeepalive.ts": { @@ -1722,16 +1712,6 @@ "count": 2 } }, - "src/lib/oneproxyRotator.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, - "src/lib/oneproxySync.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "src/lib/piiSanitizer.ts": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -4812,7 +4792,7 @@ }, "tests/unit/responses-translation-fixes.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 35 + "count": 34 } }, "tests/unit/route-edge-coverage.test.ts": { diff --git a/open-sse/config/claudeCodeCompatibleIdentity.ts b/open-sse/config/claudeCodeCompatibleIdentity.ts index 31b7997f2a..b614eea324 100644 --- a/open-sse/config/claudeCodeCompatibleIdentity.ts +++ b/open-sse/config/claudeCodeCompatibleIdentity.ts @@ -9,15 +9,19 @@ export const CLAUDE_CODE_COMPATIBLE_VERSION = CLAUDE_CODE_CLIENT_VERSION; export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = getClaudeCodeUserAgent("sdk-cli"); export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = CLAUDE_CODE_SDK_PACKAGE_VERSION; export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = CLAUDE_CODE_RUNTIME_VERSION; -const CONTEXT_1M_NATIVE_MODELS = ["claude-opus-5"]; +const CONTEXT_1M_NATIVE_MODELS = ["claude-fable-5-1", "claude-opus-5"]; export function modelHasNativeContext1m(model: string | null | undefined): boolean { const normalizedModel = String(model || "") .trim() .toLowerCase() + .replace(/^.*?(?=claude-)/, "") .replace(/-\d{8}$/, ""); return CONTEXT_1M_NATIVE_MODELS.some( - (supported) => normalizedModel === supported || normalizedModel.startsWith(`${supported}-`) + (supported) => + normalizedModel === supported || + (normalizedModel.startsWith(`${supported}-`) && + !/^\d/.test(normalizedModel.slice(supported.length + 1))) ); } diff --git a/open-sse/config/context1m.ts b/open-sse/config/context1m.ts index dab5c405b1..134df22bf8 100644 --- a/open-sse/config/context1m.ts +++ b/open-sse/config/context1m.ts @@ -34,6 +34,9 @@ export function modelSupportsContext1mBeta(model: string | null | undefined): bo .replace(/-\d{8}$/, ""); return CONTEXT_1M_SUPPORTED_MODELS.some( - (supported) => normalizedModel === supported || normalizedModel.startsWith(`${supported}-`) + (supported) => + normalizedModel === supported || + (normalizedModel.startsWith(`${supported}-`) && + !/^\d/.test(normalizedModel.slice(supported.length + 1))) ); -} \ No newline at end of file +} diff --git a/open-sse/config/providers/registry/anthropic/index.ts b/open-sse/config/providers/registry/anthropic/index.ts index 2a3726276d..2845296b15 100644 --- a/open-sse/config/providers/registry/anthropic/index.ts +++ b/open-sse/config/providers/registry/anthropic/index.ts @@ -16,6 +16,17 @@ export const anthropicProvider: RegistryEntry = { "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ + { + id: "claude-fable-5-1", + name: "Claude Fable 5.1", + contextLength: 1000000, + maxOutputTokens: 128000, + supportsReasoning: true, + supportedThinkingEfforts: ["low", "medium", "high", "xhigh", "max"], + supportsXHighEffort: true, + supportsVision: true, + unsupportedParams: ["temperature", "top_p", "top_k"], + }, { id: "claude-fable-5", name: "Claude Fable 5", diff --git a/open-sse/config/providers/registry/bedrock/index.ts b/open-sse/config/providers/registry/bedrock/index.ts index bb9273fda0..ce70091a27 100644 --- a/open-sse/config/providers/registry/bedrock/index.ts +++ b/open-sse/config/providers/registry/bedrock/index.ts @@ -9,6 +9,17 @@ export const bedrockProvider: RegistryEntry = { authHeader: "bearer", defaultContextLength: 200000, models: [ + { + id: "anthropic.claude-fable-5-1", + name: "Claude Fable 5.1 (Bedrock)", + toolCalling: true, + supportsReasoning: true, + supportedThinkingEfforts: ["low", "medium", "high", "xhigh", "max"], + supportsXHighEffort: true, + supportsVision: true, + contextLength: 1000000, + maxOutputTokens: 128000, + }, { id: "anthropic.claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Bedrock)", diff --git a/open-sse/config/providers/registry/claude/index.ts b/open-sse/config/providers/registry/claude/index.ts index 481c831c2f..ba3129678b 100644 --- a/open-sse/config/providers/registry/claude/index.ts +++ b/open-sse/config/providers/registry/claude/index.ts @@ -28,6 +28,17 @@ export const claudeProvider: RegistryEntry = { tokenUrl: "https://api.anthropic.com/v1/oauth/token", }, models: [ + { + id: "claude-fable-5-1", + name: "Claude Fable 5.1", + contextLength: 1000000, + maxOutputTokens: 128000, + supportsReasoning: true, + supportedThinkingEfforts: ["low", "medium", "high", "xhigh", "max"], + supportsXHighEffort: true, + supportsVision: true, + unsupportedParams: ["temperature", "top_p", "top_k"], + }, { id: "claude-fable-5", name: "Claude Fable 5", diff --git a/open-sse/config/providers/registry/claude/web/index.ts b/open-sse/config/providers/registry/claude/web/index.ts index c701ff9aa3..07c4872362 100644 --- a/open-sse/config/providers/registry/claude/web/index.ts +++ b/open-sse/config/providers/registry/claude/web/index.ts @@ -9,6 +9,17 @@ export const claude_webProvider: RegistryEntry = { authType: "apikey", authHeader: "cookie", models: [ + { + id: "claude-fable-5-1", + name: "Claude Fable 5.1 (web)", + toolCalling: false, + supportsReasoning: true, + supportedThinkingEfforts: ["low", "medium", "high", "xhigh", "max"], + supportsXHighEffort: true, + supportsVision: true, + contextLength: 1000000, + maxOutputTokens: 128000, + }, { id: "claude-fable-5", name: "Claude Fable 5 (web)", toolCalling: false }, { id: "claude-opus-5", diff --git a/open-sse/config/providers/registry/cursor/index.ts b/open-sse/config/providers/registry/cursor/index.ts index cc5a78f8ce..4b5b3a86de 100644 --- a/open-sse/config/providers/registry/cursor/index.ts +++ b/open-sse/config/providers/registry/cursor/index.ts @@ -1,6 +1,37 @@ -import type { RegistryEntry } from "../../shared.ts"; +import type { RegistryEntry, RegistryModel } from "../../shared.ts"; import { CURSOR_REGISTRY_VERSION, getCursorRegistryHeaders } from "../../shared.ts"; +const CLAUDE_FABLE_5_1_CAPABILITIES = { + maxOutputTokens: 128_000, +} as const; + +const ONE_MILLION_CONTEXT = 1_000_000; + +function withOneMillionContext( + models: RegistryModel[], + familyName: string, + defaultContextLength: number, + liveCatalogId: string, + supportsOneMillion: (model: RegistryModel) => boolean = () => true +): RegistryModel[] { + return models.flatMap((model) => { + const defaultContextModel = { + ...model, + contextLength: defaultContextLength, + liveCatalogIds: model.liveCatalogIds ?? [liveCatalogId], + ...(familyName.startsWith("GPT-") ? {} : { scoresAs: model.scoresAs ?? liveCatalogId }), + }; + if (!supportsOneMillion(model)) return [defaultContextModel]; + const oneMillionModel = { + ...defaultContextModel, + id: `${model.id}-1m`, + name: model.name.replace(familyName, `${familyName} 1M`), + contextLength: ONE_MILLION_CONTEXT, + }; + return [oneMillionModel, defaultContextModel]; + }); +} + export const cursorProvider: RegistryEntry = { id: "cursor", alias: "cu", @@ -18,259 +49,214 @@ export const cursorProvider: RegistryEntry = { { id: "auto-cost", name: "Auto (cost)" }, { id: "auto-balance", name: "Auto (balance)" }, { id: "auto-intelligence", name: "Auto (intelligence)" }, - // Legacy combo ids kept so existing cu/ targets are not orphaned. - { id: "composer-2", name: "Composer 2" }, - { id: "composer-2-fast", name: "Composer 2 Fast" }, - { id: "gpt-5.4-low-fast", name: "GPT 5.4 Low Fast" }, - { id: "gpt-5.3-codex-spark-preview-low", name: "GPT 5.3 Codex Spark Preview Low" }, - { id: "gpt-5.3-codex-spark-preview", name: "GPT 5.3 Codex Spark Preview" }, - { id: "gpt-5.3-codex-spark-preview-high", name: "GPT 5.3 Codex Spark Preview High" }, - { id: "gpt-5.3-codex-spark-preview-xhigh", name: "GPT 5.3 Codex Spark Preview XHigh" }, - // #11489: cursor/agy spell Claude ids - ("claude-4.6-opus-high"); - // the effort splitter strips those to "claude-4.6-opus", which is not a catalog id. - // `scoresAs` points each at the canonical - spelling so quality - // scores are inherited. Operational metadata stays on these entries. - { - id: "claude-4.6-opus-high-thinking-fast", - name: "Claude 4.6 Opus High Thinking Fast", - scoresAs: "claude-opus-4-6", - }, - { - id: "claude-4.6-opus-max-thinking-fast", - name: "Claude 4.6 Opus Max Thinking Fast", - scoresAs: "claude-opus-4-6", - }, - { - id: "claude-4.6-sonnet-medium", - name: "Claude 4.6 Sonnet Medium", - scoresAs: "claude-sonnet-4-6", - }, - { - id: "claude-4.6-sonnet-medium-thinking", - name: "Claude 4.6 Sonnet Medium Thinking", - scoresAs: "claude-sonnet-4-6", - }, - { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, - { id: "gemini-3.7-flash", name: "Gemini 3.7 Flash" }, - { id: "gemini-3-flash", name: "Gemini 3 Flash" }, - { id: "grok-4.6-medium", name: "Grok 4.6 Medium" }, - { id: "grok-4.6-fast-medium", name: "Grok 4.6 Fast Medium" }, - { id: "grok-4.6-high", name: "Grok 4.6 High" }, - { id: "grok-4.6-fast-high", name: "Grok 4.6 Fast High" }, - { id: "grok-4.6-xhigh", name: "Grok 4.6 XHigh" }, - { id: "grok-4.6-fast-xhigh", name: "Grok 4.6 Fast XHigh" }, - { id: "kimi-k3", name: "Kimi K3" }, - { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, - { id: "grok-4.3", name: "Grok 4.3" }, - { id: "grok-4.5-medium", name: "Grok 4.5 Medium" }, - { id: "grok-4.5-fast-medium", name: "Grok 4.5 Fast Medium" }, - { id: "grok-4.5-high", name: "Grok 4.5 High" }, - { id: "grok-4.5-fast-high", name: "Grok 4.5 Fast High" }, - { id: "grok-4.5-xhigh", name: "Grok 4.5 XHigh" }, - { id: "grok-4.5-fast-xhigh", name: "Grok 4.5 Fast XHigh" }, - { id: "kimi-k2.5", name: "Kimi K2.5" }, - { id: "gpt-5.3-codex-low", name: "Codex 5.3 Low" }, - { id: "gpt-5.3-codex-low-fast", name: "Codex 5.3 Low Fast" }, - { id: "gpt-5.3-codex", name: "Codex 5.3" }, - { id: "gpt-5.3-codex-fast", name: "Codex 5.3 Fast" }, - { id: "gpt-5.3-codex-high", name: "Codex 5.3 High" }, - { id: "gpt-5.3-codex-high-fast", name: "Codex 5.3 High Fast" }, - { id: "gpt-5.3-codex-xhigh", name: "Codex 5.3 Extra High" }, - { id: "gpt-5.3-codex-xhigh-fast", name: "Codex 5.3 Extra High Fast" }, - { id: "gpt-5.2", name: "GPT-5.2" }, - { id: "cursor-grok-4.5-high", name: "Cursor Grok 4.5" }, - { id: "cursor-grok-4.5-high-fast", name: "Cursor Grok 4.5 Fast" }, - { id: "composer-2.5", name: "Composer 2.5" }, - { id: "claude-opus-5-thinking-high", name: "Opus 5 1M Thinking" }, - { id: "claude-opus-5-thinking-high-fast", name: "Opus 5 1M Thinking Fast" }, - { id: "claude-opus-5-thinking-xhigh", name: "Opus 5 1M Extra High Thinking" }, - { id: "claude-opus-5-thinking-xhigh-fast", name: "Opus 5 1M Extra High Thinking Fast" }, - { id: "claude-opus-4-8-thinking-high", name: "Opus 4.8 1M Thinking" }, - { id: "claude-opus-4-8-thinking-high-fast", name: "Opus 4.8 1M Thinking Fast" }, - { id: "gpt-5.6-sol-high", name: "GPT-5.6 Sol 1M High" }, - { id: "gpt-5.6-sol-high-fast", name: "GPT-5.6 Sol High Fast" }, - { id: "gpt-5.6-sol-xhigh", name: "GPT-5.6 Sol 1M Extra High" }, - { id: "gpt-5.6-sol-xhigh-fast", name: "GPT-5.6 Sol Extra High Fast" }, - { id: "gpt-5.5-high", name: "GPT-5.5 1M High" }, - { id: "gpt-5.5-high-fast", name: "GPT-5.5 High Fast" }, - { id: "claude-fable-5-thinking-high", name: "Fable 5 1M Thinking (NO ZDR)" }, - { id: "claude-fable-5-thinking-xhigh", name: "Fable 5 1M Extra High Thinking (NO ZDR)" }, - { id: "claude-sonnet-5-thinking-high", name: "Sonnet 5 1M Thinking" }, - { id: "claude-sonnet-5-thinking-xhigh", name: "Sonnet 5 1M Extra High Thinking" }, - { id: "kimi-k3-high", name: "Kimi K3 High" }, - { id: "cursor-grok-4.5-low", name: "Cursor Grok 4.5 Low" }, - { id: "cursor-grok-4.5-low-fast", name: "Cursor Grok 4.5 Low Fast" }, - { id: "cursor-grok-4.5-medium", name: "Cursor Grok 4.5 Medium" }, - { id: "cursor-grok-4.5-medium-fast", name: "Cursor Grok 4.5 Medium Fast" }, + { id: "cursor-grok-4.6-xhigh-fast", name: "Cursor Grok 4.6 Xhigh Fast" }, + { id: "cursor-grok-4.6-xhigh", name: "Cursor Grok 4.6 Xhigh" }, + { id: "cursor-grok-4.6-high-fast", name: "Cursor Grok 4.6 High Fast" }, + { id: "cursor-grok-4.6-high", name: "Cursor Grok 4.6 High" }, + { id: "cursor-grok-4.6-medium-fast", name: "Cursor Grok 4.6 Medium Fast" }, + { id: "cursor-grok-4.6-medium", name: "Cursor Grok 4.6 Medium" }, + { id: "cursor-grok-4.6-low-fast", name: "Cursor Grok 4.6 Low Fast" }, + { id: "cursor-grok-4.6-low", name: "Cursor Grok 4.6 Low" }, { id: "composer-2.5-fast", name: "Composer 2.5 Fast" }, - { id: "claude-opus-5-low", name: "Opus 5 1M Low" }, - { id: "claude-opus-5-low-fast", name: "Opus 5 1M Low Fast" }, - { id: "claude-opus-5-medium", name: "Opus 5 1M Medium" }, - { id: "claude-opus-5-medium-fast", name: "Opus 5 1M Medium Fast" }, - { id: "claude-opus-5-high", name: "Opus 5 1M" }, - { id: "claude-opus-5-high-fast", name: "Opus 5 1M Fast" }, - { id: "claude-opus-5-thinking-low", name: "Opus 5 1M Low Thinking" }, - { id: "claude-opus-5-thinking-low-fast", name: "Opus 5 1M Low Thinking Fast" }, - { id: "claude-opus-5-thinking-medium", name: "Opus 5 1M Medium Thinking" }, - { id: "claude-opus-5-thinking-medium-fast", name: "Opus 5 1M Medium Thinking Fast" }, - { id: "claude-opus-5-thinking-max", name: "Opus 5 1M Max Thinking" }, - { id: "claude-opus-5-thinking-max-fast", name: "Opus 5 1M Max Thinking Fast" }, - { id: "claude-opus-4-8-low", name: "Opus 4.8 1M Low" }, - { id: "claude-opus-4-8-low-fast", name: "Opus 4.8 1M Low Fast" }, - { id: "claude-opus-4-8-medium", name: "Opus 4.8 1M Medium" }, - { id: "claude-opus-4-8-medium-fast", name: "Opus 4.8 1M Medium Fast" }, - { id: "claude-opus-4-8-high", name: "Opus 4.8 1M" }, - { id: "claude-opus-4-8-high-fast", name: "Opus 4.8 1M Fast" }, - { id: "claude-opus-4-8-xhigh", name: "Opus 4.8 1M Extra High" }, - { id: "claude-opus-4-8-xhigh-fast", name: "Opus 4.8 1M Extra High Fast" }, - { id: "claude-opus-4-8-max", name: "Opus 4.8 1M Max" }, - { id: "claude-opus-4-8-max-fast", name: "Opus 4.8 1M Max Fast" }, - { id: "claude-opus-4-8-thinking-low", name: "Opus 4.8 1M Low Thinking" }, - { id: "claude-opus-4-8-thinking-low-fast", name: "Opus 4.8 1M Low Thinking Fast" }, - { id: "claude-opus-4-8-thinking-medium", name: "Opus 4.8 1M Medium Thinking" }, - { id: "claude-opus-4-8-thinking-medium-fast", name: "Opus 4.8 1M Medium Thinking Fast" }, - { id: "claude-opus-4-8-thinking-xhigh", name: "Opus 4.8 1M Extra High Thinking" }, - { id: "claude-opus-4-8-thinking-xhigh-fast", name: "Opus 4.8 1M Extra High Thinking Fast" }, - { id: "claude-opus-4-8-thinking-max", name: "Opus 4.8 1M Max Thinking" }, - { id: "claude-opus-4-8-thinking-max-fast", name: "Opus 4.8 1M Max Thinking Fast" }, - { id: "gpt-5.6-sol-none", name: "GPT-5.6 Sol 1M None" }, - { id: "gpt-5.6-sol-none-fast", name: "GPT-5.6 Sol None Fast" }, - { id: "gpt-5.6-sol-low", name: "GPT-5.6 Sol 1M Low" }, - { id: "gpt-5.6-sol-low-fast", name: "GPT-5.6 Sol Low Fast" }, - { id: "gpt-5.6-sol-medium", name: "GPT-5.6 Sol 1M" }, - { id: "gpt-5.6-sol-medium-fast", name: "GPT-5.6 Sol Fast" }, - { id: "gpt-5.6-sol-max", name: "GPT-5.6 Sol 1M Max" }, - { id: "gpt-5.6-sol-max-fast", name: "GPT-5.6 Sol Max Fast" }, - { id: "gpt-5.5-none", name: "GPT-5.5 1M None" }, - { id: "gpt-5.5-none-fast", name: "GPT-5.5 None Fast" }, - { id: "gpt-5.5-low", name: "GPT-5.5 1M Low" }, - { id: "gpt-5.5-low-fast", name: "GPT-5.5 Low Fast" }, - { id: "gpt-5.5-medium", name: "GPT-5.5 1M" }, - { id: "gpt-5.5-medium-fast", name: "GPT-5.5 Fast" }, - { id: "gpt-5.5-extra-high", name: "GPT-5.5 1M Extra High" }, - { id: "gpt-5.5-extra-high-fast", name: "GPT-5.5 Extra High Fast" }, - { id: "claude-fable-5-low", name: "Fable 5 1M Low (NO ZDR)" }, - { id: "claude-fable-5-medium", name: "Fable 5 1M Medium (NO ZDR)" }, - { id: "claude-fable-5-high", name: "Fable 5 1M (NO ZDR)" }, - { id: "claude-fable-5-xhigh", name: "Fable 5 1M Extra High (NO ZDR)" }, - { id: "claude-fable-5-max", name: "Fable 5 1M Max (NO ZDR)" }, - { id: "claude-fable-5-thinking-low", name: "Fable 5 1M Low Thinking (NO ZDR)" }, - { id: "claude-fable-5-thinking-medium", name: "Fable 5 1M Medium Thinking (NO ZDR)" }, - { id: "claude-fable-5-thinking-max", name: "Fable 5 1M Max Thinking (NO ZDR)" }, - { id: "claude-sonnet-5-low", name: "Sonnet 5 1M Low" }, - { id: "claude-sonnet-5-medium", name: "Sonnet 5 1M Medium" }, - { id: "claude-sonnet-5-high", name: "Sonnet 5 1M" }, - { id: "claude-sonnet-5-xhigh", name: "Sonnet 5 1M Extra High" }, - { id: "claude-sonnet-5-max", name: "Sonnet 5 1M Max" }, - { id: "claude-sonnet-5-thinking-low", name: "Sonnet 5 1M Low Thinking" }, - { id: "claude-sonnet-5-thinking-medium", name: "Sonnet 5 1M Medium Thinking" }, - { id: "claude-sonnet-5-thinking-max", name: "Sonnet 5 1M Max Thinking" }, - { id: "gpt-5.6-terra-none", name: "GPT-5.6 Terra 1M None" }, - { id: "gpt-5.6-terra-none-fast", name: "GPT-5.6 Terra None Fast" }, - { id: "gpt-5.6-terra-low", name: "GPT-5.6 Terra 1M Low" }, - { id: "gpt-5.6-terra-low-fast", name: "GPT-5.6 Terra Low Fast" }, - { id: "gpt-5.6-terra-medium", name: "GPT-5.6 Terra 1M" }, - { id: "gpt-5.6-terra-medium-fast", name: "GPT-5.6 Terra Fast" }, - { id: "gpt-5.6-terra-high", name: "GPT-5.6 Terra 1M High" }, - { id: "gpt-5.6-terra-high-fast", name: "GPT-5.6 Terra High Fast" }, - { id: "gpt-5.6-terra-xhigh", name: "GPT-5.6 Terra 1M Extra High" }, - { id: "gpt-5.6-terra-xhigh-fast", name: "GPT-5.6 Terra Extra High Fast" }, - { id: "gpt-5.6-terra-max", name: "GPT-5.6 Terra 1M Max" }, - { id: "gpt-5.6-terra-max-fast", name: "GPT-5.6 Terra Max Fast" }, - { id: "claude-opus-4-7-low", name: "Opus 4.7 1M Low" }, - { id: "claude-opus-4-7-low-fast", name: "Opus 4.7 1M Low Fast" }, - { id: "claude-opus-4-7-medium", name: "Opus 4.7 1M Medium" }, - { id: "claude-opus-4-7-medium-fast", name: "Opus 4.7 1M Medium Fast" }, - { id: "claude-opus-4-7-high", name: "Opus 4.7 1M High" }, - { id: "claude-opus-4-7-high-fast", name: "Opus 4.7 1M High Fast" }, - { id: "claude-opus-4-7-xhigh", name: "Opus 4.7 1M" }, - { id: "claude-opus-4-7-xhigh-fast", name: "Opus 4.7 1M Fast" }, - { id: "claude-opus-4-7-max", name: "Opus 4.7 1M Max" }, - { id: "claude-opus-4-7-max-fast", name: "Opus 4.7 1M Max Fast" }, - { id: "claude-opus-4-7-thinking-low", name: "Opus 4.7 1M Low Thinking" }, - { id: "claude-opus-4-7-thinking-low-fast", name: "Opus 4.7 1M Low Thinking Fast" }, - { id: "claude-opus-4-7-thinking-medium", name: "Opus 4.7 1M Medium Thinking" }, - { id: "claude-opus-4-7-thinking-medium-fast", name: "Opus 4.7 1M Medium Thinking Fast" }, - { id: "claude-opus-4-7-thinking-high", name: "Opus 4.7 1M High Thinking" }, - { id: "claude-opus-4-7-thinking-high-fast", name: "Opus 4.7 1M High Thinking Fast" }, - { id: "claude-opus-4-7-thinking-xhigh", name: "Opus 4.7 1M Thinking" }, - { id: "claude-opus-4-7-thinking-xhigh-fast", name: "Opus 4.7 1M Thinking Fast" }, - { id: "claude-opus-4-7-thinking-max", name: "Opus 4.7 1M Max Thinking" }, - { id: "claude-opus-4-7-thinking-max-fast", name: "Opus 4.7 1M Max Thinking Fast" }, - { id: "gpt-5.4-low", name: "GPT-5.4 1M Low" }, - { id: "gpt-5.4-medium", name: "GPT-5.4 1M" }, - { id: "gpt-5.4-medium-fast", name: "GPT-5.4 Fast" }, - { id: "gpt-5.4-high", name: "GPT-5.4 1M High" }, - { id: "gpt-5.4-high-fast", name: "GPT-5.4 High Fast" }, - { id: "gpt-5.4-xhigh", name: "GPT-5.4 1M Extra High" }, - { id: "gpt-5.4-xhigh-fast", name: "GPT-5.4 Extra High Fast" }, - // #11489: cursor/agy spell Claude ids - ("claude-4.6-opus-high"); - // the effort splitter strips those to "claude-4.6-opus", which is not a catalog id. - // `scoresAs` points each at the canonical - spelling so quality - // scores are inherited. Operational metadata stays on these entries. - { id: "claude-4.6-opus-high", name: "Opus 4.6 1M", scoresAs: "claude-opus-4-6" }, - { id: "claude-4.6-opus-max", name: "Opus 4.6 1M Max", scoresAs: "claude-opus-4-6" }, - { - id: "claude-4.6-opus-high-thinking", - name: "Opus 4.6 1M Thinking", - scoresAs: "claude-opus-4-6", - }, - { - id: "claude-4.6-opus-max-thinking", - name: "Opus 4.6 1M Max Thinking", - scoresAs: "claude-opus-4-6", - }, - { id: "claude-4.5-opus-high", name: "Opus 4.5", scoresAs: "claude-opus-4-5" }, - { id: "claude-4.5-opus-high-thinking", name: "Opus 4.5 Thinking", scoresAs: "claude-opus-4-5" }, - { id: "gpt-5.2-low", name: "GPT-5.2 Low" }, - { id: "gpt-5.2-low-fast", name: "GPT-5.2 Low Fast" }, - { id: "gpt-5.2-fast", name: "GPT-5.2 Fast" }, - { id: "gpt-5.2-high", name: "GPT-5.2 High" }, - { id: "gpt-5.2-high-fast", name: "GPT-5.2 High Fast" }, - { id: "gpt-5.2-xhigh", name: "GPT-5.2 Extra High" }, - { id: "gpt-5.2-xhigh-fast", name: "GPT-5.2 Extra High Fast" }, - { id: "gpt-5.6-luna-none", name: "GPT-5.6 Luna 1M None" }, - { id: "gpt-5.6-luna-none-fast", name: "GPT-5.6 Luna None Fast" }, - { id: "gpt-5.6-luna-low", name: "GPT-5.6 Luna 1M Low" }, - { id: "gpt-5.6-luna-low-fast", name: "GPT-5.6 Luna Low Fast" }, - { id: "gpt-5.6-luna-medium", name: "GPT-5.6 Luna 1M" }, - { id: "gpt-5.6-luna-medium-fast", name: "GPT-5.6 Luna Fast" }, - { id: "gpt-5.6-luna-high", name: "GPT-5.6 Luna 1M High" }, - { id: "gpt-5.6-luna-high-fast", name: "GPT-5.6 Luna High Fast" }, - { id: "gpt-5.6-luna-xhigh", name: "GPT-5.6 Luna 1M Extra High" }, - { id: "gpt-5.6-luna-xhigh-fast", name: "GPT-5.6 Luna Extra High Fast" }, - { id: "gpt-5.6-luna-max", name: "GPT-5.6 Luna 1M Max" }, - { id: "gpt-5.6-luna-max-fast", name: "GPT-5.6 Luna Max Fast" }, - { id: "gemini-3.6-flash-minimal", name: "Gemini 3.6 Flash Minimal" }, - { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash Low" }, - { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash Medium" }, - { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash" }, - { id: "gpt-5.4-mini-none", name: "GPT-5.4 Mini None" }, - { id: "gpt-5.4-mini-low", name: "GPT-5.4 Mini Low" }, - { id: "gpt-5.4-mini-medium", name: "GPT-5.4 Mini" }, - { id: "gpt-5.4-mini-high", name: "GPT-5.4 Mini High" }, - { id: "gpt-5.4-mini-xhigh", name: "GPT-5.4 Mini Extra High" }, - { id: "gpt-5.4-nano-none", name: "GPT-5.4 Nano None" }, - { id: "gpt-5.4-nano-low", name: "GPT-5.4 Nano Low" }, - { id: "gpt-5.4-nano-medium", name: "GPT-5.4 Nano" }, - { id: "gpt-5.4-nano-high", name: "GPT-5.4 Nano High" }, - { id: "gpt-5.4-nano-xhigh", name: "GPT-5.4 Nano Extra High" }, - { id: "claude-4.5-sonnet", name: "Sonnet 4.5", scoresAs: "claude-sonnet-4-5" }, - { - id: "claude-4.5-sonnet-thinking", - name: "Sonnet 4.5 Thinking", - scoresAs: "claude-sonnet-4-5", - }, - { id: "gpt-5.1-low", name: "GPT-5.1 Low" }, - { id: "gpt-5.1", name: "GPT-5.1" }, - { id: "gpt-5.1-high", name: "GPT-5.1 High" }, - { id: "claude-4-sonnet", name: "Sonnet 4", scoresAs: "claude-sonnet-4" }, - { id: "claude-4-sonnet-thinking", name: "Sonnet 4 Thinking", scoresAs: "claude-sonnet-4" }, - { id: "gpt-5-mini", name: "GPT-5 Mini" }, + { id: "composer-2.5", name: "Composer 2.5" }, + ...withOneMillionContext( + [ + { + id: "claude-fable-5-1-thinking-max", + name: "Claude Fable 5.1 Max Thinking", + ...CLAUDE_FABLE_5_1_CAPABILITIES, + }, + { + id: "claude-fable-5-1-thinking-xhigh", + name: "Claude Fable 5.1 Xhigh Thinking", + ...CLAUDE_FABLE_5_1_CAPABILITIES, + }, + { + id: "claude-fable-5-1-thinking-high", + name: "Claude Fable 5.1 High Thinking", + ...CLAUDE_FABLE_5_1_CAPABILITIES, + }, + { + id: "claude-fable-5-1-thinking-medium", + name: "Claude Fable 5.1 Medium Thinking", + ...CLAUDE_FABLE_5_1_CAPABILITIES, + }, + { + id: "claude-fable-5-1-thinking-low", + name: "Claude Fable 5.1 Low Thinking", + ...CLAUDE_FABLE_5_1_CAPABILITIES, + }, + ], + "Claude Fable 5.1", + 300_000, + "claude-fable-5-1" + ), + ...withOneMillionContext( + [ + { id: "claude-opus-5-thinking-max-fast", name: "Claude Opus 5 Max Thinking Fast" }, + { id: "claude-opus-5-thinking-max", name: "Claude Opus 5 Max Thinking" }, + { + id: "claude-opus-5-thinking-xhigh-fast", + name: "Claude Opus 5 Xhigh Thinking Fast", + }, + { id: "claude-opus-5-thinking-xhigh", name: "Claude Opus 5 Xhigh Thinking" }, + { id: "claude-opus-5-thinking-high-fast", name: "Claude Opus 5 High Thinking Fast" }, + { id: "claude-opus-5-thinking-high", name: "Claude Opus 5 High Thinking" }, + { id: "claude-opus-5-high-fast", name: "Claude Opus 5 High Fast" }, + { id: "claude-opus-5-high", name: "Claude Opus 5 High" }, + { + id: "claude-opus-5-thinking-medium-fast", + name: "Claude Opus 5 Medium Thinking Fast", + }, + { id: "claude-opus-5-thinking-medium", name: "Claude Opus 5 Medium Thinking" }, + { id: "claude-opus-5-medium-fast", name: "Claude Opus 5 Medium Fast" }, + { id: "claude-opus-5-medium", name: "Claude Opus 5 Medium" }, + { id: "claude-opus-5-thinking-low-fast", name: "Claude Opus 5 Low Thinking Fast" }, + { id: "claude-opus-5-thinking-low", name: "Claude Opus 5 Low Thinking" }, + { id: "claude-opus-5-low-fast", name: "Claude Opus 5 Low Fast" }, + { id: "claude-opus-5-low", name: "Claude Opus 5 Low" }, + ], + "Claude Opus 5", + 300_000, + "claude-opus-5" + ), + ...withOneMillionContext( + [ + { id: "claude-opus-4-8-thinking-max-fast", name: "Claude Opus 4.8 Max Thinking Fast" }, + { id: "claude-opus-4-8-thinking-max", name: "Claude Opus 4.8 Max Thinking" }, + { id: "claude-opus-4-8-max-fast", name: "Claude Opus 4.8 Max Fast" }, + { id: "claude-opus-4-8-max", name: "Claude Opus 4.8 Max" }, + { + id: "claude-opus-4-8-thinking-xhigh-fast", + name: "Claude Opus 4.8 Xhigh Thinking Fast", + }, + { id: "claude-opus-4-8-thinking-xhigh", name: "Claude Opus 4.8 Xhigh Thinking" }, + { id: "claude-opus-4-8-xhigh-fast", name: "Claude Opus 4.8 Xhigh Fast" }, + { id: "claude-opus-4-8-xhigh", name: "Claude Opus 4.8 Xhigh" }, + { id: "claude-opus-4-8-thinking-high-fast", name: "Claude Opus 4.8 High Thinking Fast" }, + { id: "claude-opus-4-8-thinking-high", name: "Claude Opus 4.8 High Thinking" }, + { id: "claude-opus-4-8-high-fast", name: "Claude Opus 4.8 High Fast" }, + { id: "claude-opus-4-8-high", name: "Claude Opus 4.8 High" }, + { + id: "claude-opus-4-8-thinking-medium-fast", + name: "Claude Opus 4.8 Medium Thinking Fast", + }, + { id: "claude-opus-4-8-thinking-medium", name: "Claude Opus 4.8 Medium Thinking" }, + { id: "claude-opus-4-8-medium-fast", name: "Claude Opus 4.8 Medium Fast" }, + { id: "claude-opus-4-8-medium", name: "Claude Opus 4.8 Medium" }, + { id: "claude-opus-4-8-thinking-low-fast", name: "Claude Opus 4.8 Low Thinking Fast" }, + { id: "claude-opus-4-8-thinking-low", name: "Claude Opus 4.8 Low Thinking" }, + { id: "claude-opus-4-8-low-fast", name: "Claude Opus 4.8 Low Fast" }, + { id: "claude-opus-4-8-low", name: "Claude Opus 4.8 Low" }, + ], + "Claude Opus 4.8", + 300_000, + "claude-opus-4-8" + ), + ...withOneMillionContext( + [ + { id: "claude-sonnet-5-thinking-max", name: "Claude Sonnet 5 Max Thinking" }, + { id: "claude-sonnet-5-max", name: "Claude Sonnet 5 Max" }, + { id: "claude-sonnet-5-thinking-xhigh", name: "Claude Sonnet 5 Xhigh Thinking" }, + { id: "claude-sonnet-5-xhigh", name: "Claude Sonnet 5 Xhigh" }, + { id: "claude-sonnet-5-thinking-high", name: "Claude Sonnet 5 High Thinking" }, + { id: "claude-sonnet-5-high", name: "Claude Sonnet 5 High" }, + { id: "claude-sonnet-5-thinking-medium", name: "Claude Sonnet 5 Medium Thinking" }, + { id: "claude-sonnet-5-medium", name: "Claude Sonnet 5 Medium" }, + { id: "claude-sonnet-5-thinking-low", name: "Claude Sonnet 5 Low Thinking" }, + { id: "claude-sonnet-5-low", name: "Claude Sonnet 5 Low" }, + ], + "Claude Sonnet 5", + 300_000, + "claude-sonnet-5" + ), + ...withOneMillionContext( + [ + { id: "claude-4.6-sonnet-max-thinking", name: "Claude Sonnet 4.6 Max Thinking" }, + { id: "claude-4.6-sonnet-max", name: "Claude Sonnet 4.6 Max" }, + { id: "claude-4.6-sonnet-high-thinking", name: "Claude Sonnet 4.6 High Thinking" }, + { id: "claude-4.6-sonnet-high", name: "Claude Sonnet 4.6 High" }, + { id: "claude-4.6-sonnet-medium-thinking", name: "Claude Sonnet 4.6 Medium Thinking" }, + { id: "claude-4.6-sonnet-medium", name: "Claude Sonnet 4.6 Medium" }, + { id: "claude-4.6-sonnet-low-thinking", name: "Claude Sonnet 4.6 Low Thinking" }, + { id: "claude-4.6-sonnet-low", name: "Claude Sonnet 4.6 Low" }, + ], + "Claude Sonnet 4.6", + 200_000, + "claude-sonnet-4-6" + ), + { id: "claude-4.5-haiku-thinking", name: "Claude Haiku 4.5 Thinking" }, + { id: "claude-4.5-haiku", name: "Claude Haiku 4.5" }, + ...withOneMillionContext( + [ + { id: "gpt-5.6-sol-max-fast", name: "GPT-5.6 Sol Max Fast" }, + { id: "gpt-5.6-sol-max", name: "GPT-5.6 Sol Max" }, + { id: "gpt-5.6-sol-xhigh-fast", name: "GPT-5.6 Sol Xhigh Fast" }, + { id: "gpt-5.6-sol-xhigh", name: "GPT-5.6 Sol Xhigh" }, + { id: "gpt-5.6-sol-high-fast", name: "GPT-5.6 Sol High Fast" }, + { id: "gpt-5.6-sol-high", name: "GPT-5.6 Sol High" }, + { id: "gpt-5.6-sol-medium-fast", name: "GPT-5.6 Sol Medium Fast" }, + { id: "gpt-5.6-sol-medium", name: "GPT-5.6 Sol Medium" }, + { id: "gpt-5.6-sol-low-fast", name: "GPT-5.6 Sol Low Fast" }, + { id: "gpt-5.6-sol-low", name: "GPT-5.6 Sol Low" }, + { id: "gpt-5.6-sol-none-fast", name: "GPT-5.6 Sol None Fast" }, + { id: "gpt-5.6-sol-none", name: "GPT-5.6 Sol None" }, + ], + "GPT-5.6 Sol", + 272_000, + "gpt-5.6-sol", + (model) => !model.id.endsWith("-fast") + ), + ...withOneMillionContext( + [ + { id: "gpt-5.6-terra-max-fast", name: "GPT-5.6 Terra Max Fast" }, + { id: "gpt-5.6-terra-max", name: "GPT-5.6 Terra Max" }, + { id: "gpt-5.6-terra-xhigh-fast", name: "GPT-5.6 Terra Xhigh Fast" }, + { id: "gpt-5.6-terra-xhigh", name: "GPT-5.6 Terra Xhigh" }, + { id: "gpt-5.6-terra-high-fast", name: "GPT-5.6 Terra High Fast" }, + { id: "gpt-5.6-terra-high", name: "GPT-5.6 Terra High" }, + { id: "gpt-5.6-terra-medium-fast", name: "GPT-5.6 Terra Medium Fast" }, + { id: "gpt-5.6-terra-medium", name: "GPT-5.6 Terra Medium" }, + { id: "gpt-5.6-terra-low-fast", name: "GPT-5.6 Terra Low Fast" }, + { id: "gpt-5.6-terra-low", name: "GPT-5.6 Terra Low" }, + { id: "gpt-5.6-terra-none-fast", name: "GPT-5.6 Terra None Fast" }, + { id: "gpt-5.6-terra-none", name: "GPT-5.6 Terra None" }, + ], + "GPT-5.6 Terra", + 272_000, + "gpt-5.6-terra", + (model) => !model.id.endsWith("-fast") + ), + ...withOneMillionContext( + [ + { id: "gpt-5.6-luna-max-fast", name: "GPT-5.6 Luna Max Fast" }, + { id: "gpt-5.6-luna-max", name: "GPT-5.6 Luna Max" }, + { id: "gpt-5.6-luna-xhigh-fast", name: "GPT-5.6 Luna Xhigh Fast" }, + { id: "gpt-5.6-luna-xhigh", name: "GPT-5.6 Luna Xhigh" }, + { id: "gpt-5.6-luna-high-fast", name: "GPT-5.6 Luna High Fast" }, + { id: "gpt-5.6-luna-high", name: "GPT-5.6 Luna High" }, + { id: "gpt-5.6-luna-medium-fast", name: "GPT-5.6 Luna Medium Fast" }, + { id: "gpt-5.6-luna-medium", name: "GPT-5.6 Luna Medium" }, + { id: "gpt-5.6-luna-low-fast", name: "GPT-5.6 Luna Low Fast" }, + { id: "gpt-5.6-luna-low", name: "GPT-5.6 Luna Low" }, + { id: "gpt-5.6-luna-none-fast", name: "GPT-5.6 Luna None Fast" }, + { id: "gpt-5.6-luna-none", name: "GPT-5.6 Luna None" }, + ], + "GPT-5.6 Luna", + 272_000, + "gpt-5.6-luna", + (model) => !model.id.endsWith("-fast") + ), + { id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash High" }, + { id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash Medium" }, + { id: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash Low" }, + { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, + { id: "kimi-k3-max", name: "Kimi K3 Max" }, + { id: "kimi-k3-high", name: "Kimi K3 High" }, { id: "kimi-k3-low", name: "Kimi K3 Low" }, - { id: "kimi-k3-max", name: "Kimi K3" }, - { id: "glm-5.2-high", name: "GLM 5.2" }, + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, { id: "glm-5.2-max", name: "GLM 5.2 Max" }, + { id: "glm-5.2-high", name: "GLM 5.2 High" }, ], }; diff --git a/open-sse/config/providers/registry/devin/catalog.ts b/open-sse/config/providers/registry/devin/catalog.ts index d1e5c75894..6ab53fda12 100644 --- a/open-sse/config/providers/registry/devin/catalog.ts +++ b/open-sse/config/providers/registry/devin/catalog.ts @@ -1,115 +1,150 @@ import type { RegistryModel } from "../../shared.ts"; +type EffortVariant = readonly [suffix: string, label: string]; + +const QUALITY_EFFORTS: readonly EffortVariant[] = [ + ["max", "Max"], + ["xhigh", "XHigh"], + ["high", "High"], + ["medium", "Medium"], + ["low", "Low"], +]; + +const GPT_EFFORTS: readonly EffortVariant[] = [ + ["max", "Max Thinking"], + ["xhigh", "XHigh Thinking"], + ["high", "High Thinking"], + ["medium", "Medium Thinking"], + ["low", "Low Thinking"], + ["none", "No Thinking"], +]; + +function model( + id: string, + name: string, + maxOutputTokens?: number, + contextLength?: number +): RegistryModel { + return { + id, + name, + ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }), + ...(contextLength === undefined ? {} : { contextLength }), + }; +} + +function effortModels( + id: string, + name: string, + maxOutputTokens: number, + contextLength: number | undefined, + efforts: readonly EffortVariant[] = QUALITY_EFFORTS +): RegistryModel[] { + return efforts.map(([suffix, label]) => + model(`${id}-${suffix}`, `${name} ${label}`, maxOutputTokens, contextLength) + ); +} + +function fastEffortModels( + id: string, + name: string, + maxOutputTokens: number, + contextLength: number +): RegistryModel[] { + return QUALITY_EFFORTS.flatMap(([suffix, label]) => [ + model(`${id}-${suffix}-fast`, `${name} ${label} Fast`, maxOutputTokens, contextLength), + model(`${id}-${suffix}`, `${name} ${label}`, maxOutputTokens, contextLength), + ]); +} + +function gptModels(id: string, name: string): RegistryModel[] { + return GPT_EFFORTS.flatMap(([suffix, label]) => [ + model(`${id}-${suffix}-priority`, `${name} ${label} Fast`, 128_000, 1_000_000), + model(`${id}-${suffix}`, `${name} ${label}`, 128_000, 1_000_000), + ]); +} + +/** + * Curated from the authenticated `devin models list --format json` response on + * 2026-09-02. Keep this deliberately smaller than Devin's full live catalog: + * these are the operator-selected models OmniRoute intends to expose. + */ export const DEVIN_MODEL_CATALOG: RegistryModel[] = [ - // Cognition / SWE — default model family recommended for coding tasks - { id: "swe-1-7-lightning", name: "SWE-1.7 Lightning", contextLength: 202752 }, - { id: "swe-1-7", name: "SWE-1.7", contextLength: 262000 }, - { id: "swe-1-6-fast", name: "SWE-1.6 Fast" }, - { id: "swe-1-6", name: "SWE-1.6" }, - // Claude Fable 5 - { id: "claude-5-fable-max", name: "Claude Fable 5 Max", contextLength: 1000000 }, - { id: "claude-5-fable-xhigh", name: "Claude Fable 5 XHigh", contextLength: 1000000 }, - { id: "claude-5-fable-high", name: "Claude Fable 5 High", contextLength: 1000000 }, - { id: "claude-5-fable-medium", name: "Claude Fable 5 Medium", contextLength: 1000000 }, - { id: "claude-5-fable-low", name: "Claude Fable 5 Low", contextLength: 1000000 }, - // Claude Opus 5 - { id: "claude-opus-5-max", name: "Claude Opus 5 Max", contextLength: 1000000 }, - { id: "claude-opus-5-xhigh", name: "Claude Opus 5 XHigh", contextLength: 1000000 }, - { id: "claude-opus-5-high", name: "Claude Opus 5 High", contextLength: 1000000 }, - { id: "claude-opus-5-medium", name: "Claude Opus 5 Medium", contextLength: 1000000 }, - { id: "claude-opus-5-low", name: "Claude Opus 5 Low", contextLength: 1000000 }, - // Claude Opus 4.8 - { id: "claude-opus-4-8-max", name: "Claude Opus 4.8 Max", contextLength: 1000000 }, - { id: "claude-opus-4-8-xhigh", name: "Claude Opus 4.8 XHigh", contextLength: 1000000 }, - { id: "claude-opus-4-8-high", name: "Claude Opus 4.8 High", contextLength: 1000000 }, - { id: "claude-opus-4-8-medium", name: "Claude Opus 4.8 Medium", contextLength: 1000000 }, - { id: "claude-opus-4-8-low", name: "Claude Opus 4.8 Low", contextLength: 1000000 }, - // Claude Opus 4.7 - { id: "claude-opus-4-7-max", name: "Claude Opus 4.7 Max", contextLength: 1000000 }, - { id: "claude-opus-4-7-xhigh", name: "Claude Opus 4.7 XHigh", contextLength: 1000000 }, - { id: "claude-opus-4-7-high", name: "Claude Opus 4.7 High", contextLength: 1000000 }, - { id: "claude-opus-4-7-medium", name: "Claude Opus 4.7 Medium", contextLength: 1000000 }, - { id: "claude-opus-4-7-low", name: "Claude Opus 4.7 Low", contextLength: 1000000 }, - // Claude Opus 4.6 - { - id: "claude-opus-4-6-thinking-1m", - name: "Claude Opus 4.6 Thinking 1M", - contextLength: 1000000, - }, - { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking", contextLength: 200000 }, - { id: "claude-opus-4-6-1m", name: "Claude Opus 4.6 1M", contextLength: 1000000 }, - { id: "claude-opus-4-6", name: "Claude Opus 4.6", contextLength: 200000 }, - // Claude Sonnet 5 - { id: "claude-sonnet-5-max", name: "Claude Sonnet 5 Max", contextLength: 1000000 }, - { id: "claude-sonnet-5-xhigh", name: "Claude Sonnet 5 XHigh", contextLength: 1000000 }, - { id: "claude-sonnet-5-high", name: "Claude Sonnet 5 High", contextLength: 1000000 }, - { id: "claude-sonnet-5-medium", name: "Claude Sonnet 5 Medium", contextLength: 1000000 }, - { id: "claude-sonnet-5-low", name: "Claude Sonnet 5 Low", contextLength: 1000000 }, - // Claude Sonnet 4.6 - { - id: "claude-sonnet-4-6-thinking-1m", - name: "Claude Sonnet 4.6 Thinking 1M", - contextLength: 1000000, - }, - { - id: "claude-sonnet-4-6-thinking", - name: "Claude Sonnet 4.6 Thinking", - contextLength: 200000, - }, - { id: "claude-sonnet-4-6-1m", name: "Claude Sonnet 4.6 1M", contextLength: 1000000 }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", contextLength: 200000 }, - // GPT-5.6 - { id: "gpt-5-6-sol-max", name: "GPT-5.6 Sol Max", contextLength: 1000000 }, - { id: "gpt-5-6-sol-xhigh", name: "GPT-5.6 Sol XHigh", contextLength: 1000000 }, - { id: "gpt-5-6-sol-high", name: "GPT-5.6 Sol High", contextLength: 1000000 }, - { id: "gpt-5-6-sol-medium", name: "GPT-5.6 Sol Medium", contextLength: 1000000 }, - { id: "gpt-5-6-sol-low", name: "GPT-5.6 Sol Low", contextLength: 1000000 }, - /// Terra - { id: "gpt-5-6-terra-max", name: "GPT-5.6 Terra Max", contextLength: 1000000 }, - { id: "gpt-5-6-terra-xhigh", name: "GPT-5.6 Terra XHigh", contextLength: 1000000 }, - { id: "gpt-5-6-terra-high", name: "GPT-5.6 Terra High", contextLength: 1000000 }, - { id: "gpt-5-6-terra-medium", name: "GPT-5.6 Terra Medium", contextLength: 1000000 }, - { id: "gpt-5-6-terra-low", name: "GPT-5.6 Terra Low", contextLength: 1000000 }, - /// Luna - { id: "gpt-5-6-luna-max", name: "GPT-5.6 Luna Max", contextLength: 1000000 }, - { id: "gpt-5-6-luna-xhigh", name: "GPT-5.6 Luna XHigh", contextLength: 1000000 }, - { id: "gpt-5-6-luna-high", name: "GPT-5.6 Luna High", contextLength: 1000000 }, - { id: "gpt-5-6-luna-medium", name: "GPT-5.6 Luna Medium", contextLength: 1000000 }, - { id: "gpt-5-6-luna-low", name: "GPT-5.6 Luna Low", contextLength: 1000000 }, - // GPT-5.5 - { id: "gpt-5-5-xhigh", name: "GPT-5.5 XHigh", contextLength: 272000 }, - { id: "gpt-5-5-high", name: "GPT-5.5 High", contextLength: 272000 }, - { id: "gpt-5-5-medium", name: "GPT-5.5 Medium", contextLength: 272000 }, - { id: "gpt-5-5-low", name: "GPT-5.5 Low", contextLength: 272000 }, - // Gemini - { id: "gemini-3-1-pro-high", name: "Gemini 3.1 Pro High", contextLength: 1048576 }, - { id: "gemini-3-1-pro-low", name: "Gemini 3.1 Pro Low", contextLength: 1048576 }, - { id: "gemini-3-7-flash-high", name: "Gemini 3.7 Flash High" }, - { id: "gemini-3-7-flash-medium", name: "Gemini 3.7 Flash Medium" }, - { id: "gemini-3-7-flash-low", name: "Gemini 3.7 Flash Low" }, - { id: "gemini-3-7-flash-minimal", name: "Gemini 3.7 Flash Minimal" }, - // Grok - { id: "grok-4-5-high", name: "Grok 4.5 High", contextLength: 500000 }, - { id: "grok-4-5-medium", name: "Grok 4.5 Medium", contextLength: 500000 }, - { id: "grok-4-5-low", name: "Grok 4.5 Low", contextLength: 500000 }, - // GLM - { id: "glm-5-2-max-1m", name: "GLM-5.2 Max 1M", contextLength: 1000000 }, - { id: "glm-5-2-max", name: "GLM-5.2 Max" }, - { id: "glm-5-2-1m", name: "GLM-5.2 High 1M", contextLength: 1000000 }, - { id: "glm-5-2", name: "GLM-5.2 High" }, - // Kimi - { id: "kimi-k3-max", name: "Kimi K3 Max" }, - { id: "kimi-k3-high", name: "Kimi K3 High" }, - { id: "kimi-k3-low", name: "Kimi K3 Low" }, - { id: "kimi-k2-7", name: "Kimi K2.7", contextLength: 262144 }, - // Inkling - { id: "inkling-max", name: "Inkling Max" }, - { id: "inkling-xhigh", name: "Inkling XHigh" }, - { id: "inkling-high", name: "Inkling High" }, - { id: "inkling-medium", name: "Inkling Medium" }, - { id: "inkling-low", name: "Inkling Low" }, - { id: "inkling-none", name: "Inkling None" }, - // Others - { id: "deepseek-v4", name: "DeepSeek V4 Pro", contextLength: 1048576 }, - { id: "nemotron-3-ultra-nvfp4", name: "Nemotron 3 Ultra", contextLength: 262144 }, + ...effortModels("claude-fable-5-1", "Claude Fable 5.1", 128_000, 1_000_000), + ...fastEffortModels("claude-opus-5", "Claude Opus 5", 128_000, 1_000_000), + ...fastEffortModels("claude-opus-4-8", "Claude Opus 4.8", 128_000, 1_000_000), + ...effortModels("claude-sonnet-5", "Claude Sonnet 5", 128_000, 1_000_000), + + model("claude-sonnet-4-6-thinking-1m", "Claude Sonnet 4.6 Thinking 1M", 128_000, 1_000_000), + model("claude-sonnet-4-6-1m", "Claude Sonnet 4.6 1M", 128_000, 1_000_000), + model("claude-sonnet-4-6-thinking", "Claude Sonnet 4.6 Thinking", 128_000, 200_000), + model("claude-sonnet-4-6", "Claude Sonnet 4.6", 128_000, 200_000), + model("MODEL_PRIVATE_11", "Claude Haiku 4.5", 64_000, 200_000), + + ...gptModels("gpt-5-6-sol", "GPT-5.6 Sol"), + ...gptModels("gpt-5-6-terra", "GPT-5.6 Terra"), + ...gptModels("gpt-5-6-luna", "GPT-5.6 Luna"), + + ...effortModels("kimi-k3", "Kimi K3", 131_072, 1_048_576, [ + ["max", "Max"], + ["high", "High"], + ["low", "Low"], + ]), + model("kimi-k2-7", "Kimi K2.7", 16_000, 262_144), + + ...effortModels("glm-5-3", "GLM-5.3", 128_000, 1_000_000, [ + ["max", "Max"], + ["high", "High"], + ["low", "Low"], + ]), + ...effortModels("glm-5-3-flash", "GLM-5.3 Flash", 128_000, 1_000_000, [ + ["max", "Max"], + ["high", "High"], + ["low", "Low"], + ]), + + model("swe-1-7", "SWE-1.7 Max", 128_000, 262_000), + model("swe-1-7-medium", "SWE-1.7 Medium", 128_000, 262_000), + model("swe-1-7-lightning", "SWE-1.7 Lightning Max", 96_000, 202_752), + model("swe-1-7-lightning-medium", "SWE-1.7 Lightning Medium", 96_000, 202_752), + model("adaptive", "Adaptive"), + + ...effortModels("grok-4-6", "Grok 4.6", 100_000, 500_000, [ + ["xhigh", "XHigh"], + ["high", "High"], + ["medium", "Medium"], + ["low", "Low"], + ]), + ...effortModels("inkling", "Inkling", 131_072, undefined, [ + ["max", "Max"], + ["xhigh", "X-High"], + ["high", "High"], + ["medium", "Medium"], + ["low", "Low"], + ["none", "None"], + ]), + ...effortModels("deepseek-v4-flash", "DeepSeek V4 Flash", 384_000, 1_000_000, [ + ["max", "Max"], + ["high", "High"], + ["low", "Low"], + ]), + ...effortModels("nemotron-3-ultra", "Nemotron 3 Ultra", 32_768, 262_144, [ + ["high", "High"], + ["medium", "Medium"], + ["none", "None"], + ]), + ...effortModels("gemini-3-7-flash", "Gemini 3.7 Flash", 65_535, 1_048_576, [ + ["high", "High"], + ["medium", "Medium"], + ["low", "Low"], + ]), + ...effortModels("gemini-3-1-pro", "Gemini 3.1 Pro", 65_535, 1_048_576, [ + ["high", "High Thinking"], + ["low", "Low Thinking"], + ]), + ...effortModels("deepseek-v4-pro", "DeepSeek V4 Pro", 384_000, 1_000_000, [ + ["max", "Max"], + ["high", "High"], + ["low", "Low"], + ]), ]; diff --git a/open-sse/config/providers/registry/vertex/index.ts b/open-sse/config/providers/registry/vertex/index.ts index 1eac20fc15..4b72b9f7c4 100644 --- a/open-sse/config/providers/registry/vertex/index.ts +++ b/open-sse/config/providers/registry/vertex/index.ts @@ -27,6 +27,7 @@ export const vertexProvider: RegistryEntry = { { id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro (Vertex Partner)" }, { id: "Qwen3.6-35B-A3B", name: "Qwen3.6 35B A3B (Vertex Partner)" }, { id: "GLM-5.1-FP8", name: "GLM-5.1 (Vertex Partner)" }, + { id: "claude-fable-5-1", name: "Claude Fable 5.1 (Vertex)", targetFormat: "claude" }, { id: "claude-fable-5", name: "Claude Fable 5 (Vertex)", targetFormat: "claude" }, { id: "claude-opus-5", name: "Claude Opus 5 (Vertex)", targetFormat: "claude" }, { id: "claude-sonnet-5", name: "Claude Sonnet 5 (Vertex)", targetFormat: "claude" }, diff --git a/open-sse/config/providers/registry/vertex/partner/index.ts b/open-sse/config/providers/registry/vertex/partner/index.ts index 4cdcd5d0b5..6524cd6a2b 100644 --- a/open-sse/config/providers/registry/vertex/partner/index.ts +++ b/open-sse/config/providers/registry/vertex/partner/index.ts @@ -13,6 +13,7 @@ export const vertex_partnerProvider: RegistryEntry = { { id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" }, { id: "Qwen3.6-35B-A3B", name: "Qwen 3.6 35B A3B" }, { id: "GLM-5.1-FP8", name: "GLM 5.1" }, + { id: "claude-fable-5-1", name: "Claude Fable 5.1", targetFormat: "claude" }, { id: "claude-fable-5", name: "Claude Fable 5", targetFormat: "claude" }, { id: "claude-opus-5", name: "Claude Opus 5", targetFormat: "claude" }, { id: "claude-sonnet-5", name: "Claude Sonnet 5", targetFormat: "claude" }, diff --git a/open-sse/services/autoCombo/__tests__/scoresAs-11489.test.ts b/open-sse/services/autoCombo/__tests__/scoresAs-11489.test.ts index b1d8d31b36..b3f719558f 100644 --- a/open-sse/services/autoCombo/__tests__/scoresAs-11489.test.ts +++ b/open-sse/services/autoCombo/__tests__/scoresAs-11489.test.ts @@ -59,11 +59,9 @@ describe("#11489 resolveScoresAs", () => { expect(resolveScoresAs("claude-sonnet-5")).toEqual({ base: "claude-sonnet-5", via: null }); }); - it("resolves the cursor/agy spelling of a Claude model to its canonical id", () => { - // `claude-4.6-opus-high` strips to `claude-4.6-opus`, which is not a catalog - // id — the canonical spelling is `claude-opus-4-6`. Explicit registry data. - expect(resolveScoresAs("claude-4.6-opus-high")).toEqual({ - base: "claude-opus-4-6", + it("resolves curated Cursor Claude variants to their canonical ids", () => { + expect(resolveScoresAs("claude-fable-5-1-thinking-high")).toEqual({ + base: "claude-fable-5-1", via: "explicit", }); expect(resolveScoresAs("claude-4.6-sonnet-medium")).toEqual({ diff --git a/open-sse/services/modelFamilyFallback.ts b/open-sse/services/modelFamilyFallback.ts index 5e80fe0d40..6226c1217b 100644 --- a/open-sse/services/modelFamilyFallback.ts +++ b/open-sse/services/modelFamilyFallback.ts @@ -78,8 +78,9 @@ const FAMILY_FALLBACK_TEMPLATES: Record = { "gemini-2.5-pro": ["gemini-2.5-pro-preview-06-05", "gemini-2.5-pro-exp-03-25"], "gemini-2.5-pro-preview-06-05": ["gemini-2.5-pro", "gemini-2.5-pro-exp-03-25"], - // Claude Mythos family (Fable 5) — flagship falls to the next-best Opus - // tiers before the cheaper Sonnet, matching the Opus family ordering. + // Claude Mythos family — prefer the previous Fable before falling to Opus + // tiers and then the cheaper Sonnet, matching the flagship ordering. + "claude-fable-5-1": ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"], "claude-fable-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-sonnet-5"], // Claude Opus family diff --git a/open-sse/services/providerCostData.ts b/open-sse/services/providerCostData.ts index d9a4e0a8ab..8464635c92 100644 --- a/open-sse/services/providerCostData.ts +++ b/open-sse/services/providerCostData.ts @@ -1,4 +1,4 @@ -import type { TierAssignment } from "./tierTypes"; +import { getPricingForModel as getDefaultPricingForModel } from "@/shared/constants/pricing"; import type { TierConfig } from "./tierTypes"; export interface ModelPricing { @@ -11,6 +11,7 @@ export interface ModelPricing { export const KNOWN_MODEL_PRICING: Record = { "gpt-4o": { inputCostPer1M: 2.5, outputCostPer1M: 10.0, isFree: false }, "gpt-4o-mini": { inputCostPer1M: 0.15, outputCostPer1M: 0.6, isFree: false }, + "claude-fable-5-1": { inputCostPer1M: 10.0, outputCostPer1M: 50.0, isFree: false }, "claude-fable-5": { inputCostPer1M: 15.0, outputCostPer1M: 75.0, isFree: false }, "claude-opus-5": { inputCostPer1M: 5.0, outputCostPer1M: 25.0, isFree: false }, "claude-opus-4-8": { inputCostPer1M: 15.0, outputCostPer1M: 75.0, isFree: false }, @@ -37,14 +38,26 @@ export const KNOWN_MODEL_PRICING: Record = { }; export function getModelPricing(provider: string, model: string): ModelPricing { - const directKey = model.toLowerCase(); - if (KNOWN_MODEL_PRICING[directKey]) { - return KNOWN_MODEL_PRICING[directKey]; - } const providerKey = `${provider}/${model}`.toLowerCase(); if (KNOWN_MODEL_PRICING[providerKey]) { return KNOWN_MODEL_PRICING[providerKey]; } + const providerPricing = getDefaultPricingForModel(provider, model); + if (providerPricing) { + const inputCostPer1M = Number(providerPricing.input); + const outputCostPer1M = Number(providerPricing.output); + if (Number.isFinite(inputCostPer1M) && Number.isFinite(outputCostPer1M)) { + return { + inputCostPer1M, + outputCostPer1M, + isFree: inputCostPer1M === 0 && outputCostPer1M === 0, + }; + } + } + const directKey = model.toLowerCase(); + if (KNOWN_MODEL_PRICING[directKey]) { + return KNOWN_MODEL_PRICING[directKey]; + } return { inputCostPer1M: 5.0, outputCostPer1M: 15.0, isFree: false }; } diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index 21164b6ed1..0e3f642682 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -24,6 +24,10 @@ import { encodeSelectedImageBody, type EncodedImage, } from "./cursorAgentProtobuf/imageEncoding.ts"; +import { + CURSOR_EFFORT_SUFFIXES, + resolveOneMillionContextModel, +} from "./cursorAgentProtobuf/requestedModelParameters.ts"; import { WT_VARINT, WT_LEN, @@ -41,6 +45,7 @@ import { findField, decodeStringField, decodeVarintField, + type Field, } from "./cursorAgentProtobuf/wire.ts"; // ─── Field numbers (from agent.proto descriptor) ─────────────────────────── @@ -311,8 +316,6 @@ export function normalizeCursorModelId(modelId: string): string { // Grok (`cursor-grok-*` / legacy `grok-*`) follows the Claude-style `effort` // parameter. Without the split, ids like `cursor-grok-4.5-high` return empty // turns (same symptom as #7289). Combined `-high-fast` is supported. -const CURSOR_EFFORT_SUFFIXES = ["low", "medium", "high", "xhigh", "max"] as const; - /** * If `normalized` starts with `prefix` and ends with one of the known effort * suffixes, split it into the base model id plus a `{id: paramId, value}` @@ -432,6 +435,8 @@ export function resolveRequestedModel( }; } } + const oneMillionContext = resolveOneMillionContextModel(normalized); + if (oneMillionContext) return oneMillionContext; // Live catalog is authoritative for exact ids (flattened effort variants). if (opts?.liveCatalogIds?.has(normalized)) { return { modelId: normalized, parameters: [] }; @@ -652,6 +657,41 @@ export type DecodedDelta = | { kind: "kv_server_message" } | { kind: "unknown"; field: number }; +type InteractionUpdateDecoder = (field: Field) => DecodedDelta[]; + +const INTERACTION_UPDATE_DECODERS: Partial> = { + [IU_TEXT_DELTA]: (field) => + field.wireType === WT_LEN + ? [{ kind: "text", text: decodeStringField(field.bytes, TDU_TEXT) }] + : [], + [IU_THINKING_DELTA]: (field) => + field.wireType === WT_LEN + ? [{ kind: "thinking", text: decodeStringField(field.bytes, TDU_TEXT) }] + : [], + [IU_THINKING_COMPLETED]: () => [{ kind: "thinking_complete" }], + [IU_TOOL_CALL_STARTED]: () => [{ kind: "tool_call_started" }], + [IU_TOOL_CALL_COMPLETED]: (field) => { + const deltas: DecodedDelta[] = []; + if (field.wireType === WT_LEN) { + const todoWrite = decodeNativeTodoWriteCompletion(field.bytes); + if (todoWrite) deltas.push(todoWrite); + } + deltas.push({ kind: "tool_call_completed" }); + return deltas; + }, + [IU_TOKEN_DELTA]: (field) => + field.wireType === WT_LEN + ? [{ kind: "token_delta", tokens: decodeVarintField(field.bytes, 1) }] + : [], + [IU_HEARTBEAT]: () => [{ kind: "heartbeat" }], + [IU_TURN_ENDED]: () => [{ kind: "turn_ended" }], +}; + +function decodeInteractionUpdate(field: Field): DecodedDelta[] { + const decoder = INTERACTION_UPDATE_DECODERS[field.fieldNumber]; + return decoder ? decoder(field) : [{ kind: "unknown", field: field.fieldNumber }]; +} + export function decodeAgentServerMessage(payload: Buffer): DecodedDelta[] { const out: DecodedDelta[] = []; for (const top of decodeFields(payload)) { @@ -661,45 +701,7 @@ export function decodeAgentServerMessage(payload: Buffer): DecodedDelta[] { } if (top.fieldNumber !== ASM_INTERACTION_UPDATE || top.wireType !== 2) continue; for (const update of decodeFields(top.bytes)) { - if (update.wireType !== 2 && update.wireType !== 0) continue; - switch (update.fieldNumber) { - case IU_TEXT_DELTA: - if (update.wireType === 2) { - out.push({ kind: "text", text: decodeStringField(update.bytes, TDU_TEXT) }); - } - break; - case IU_THINKING_DELTA: - if (update.wireType === 2) { - out.push({ kind: "thinking", text: decodeStringField(update.bytes, TDU_TEXT) }); - } - break; - case IU_THINKING_COMPLETED: - out.push({ kind: "thinking_complete" }); - break; - case IU_TOOL_CALL_STARTED: - out.push({ kind: "tool_call_started" }); - break; - case IU_TOOL_CALL_COMPLETED: - if (update.wireType === 2) { - const todoWrite = decodeNativeTodoWriteCompletion(update.bytes); - if (todoWrite) out.push(todoWrite); - } - out.push({ kind: "tool_call_completed" }); - break; - case IU_TOKEN_DELTA: - if (update.wireType === 2) { - out.push({ kind: "token_delta", tokens: decodeVarintField(update.bytes, 1) }); - } - break; - case IU_HEARTBEAT: - out.push({ kind: "heartbeat" }); - break; - case IU_TURN_ENDED: - out.push({ kind: "turn_ended" }); - break; - default: - out.push({ kind: "unknown", field: update.fieldNumber }); - } + out.push(...decodeInteractionUpdate(update)); } } return out; @@ -750,52 +752,44 @@ export type KvServerEvent = requestMetadata: Buffer | null; }; +function findLengthDelimitedField(fields: Field[], fieldNumber: number): Buffer | null { + const field = findField(fields, fieldNumber); + return field?.wireType === WT_LEN ? field.bytes : null; +} + +function decodeBlobId(payload: Buffer, fieldNumber: number): Buffer { + return findLengthDelimitedField(decodeFields(payload), fieldNumber) ?? Buffer.alloc(0); +} + +function decodeSetBlobArgs(payload: Buffer): { blobId: Buffer; blobData: Buffer } { + const fields = decodeFields(payload); + return { + blobId: findLengthDelimitedField(fields, SBA_BLOB_ID) ?? Buffer.alloc(0), + blobData: findLengthDelimitedField(fields, SBA_BLOB_DATA) ?? Buffer.alloc(0), + }; +} + export function decodeKvServerEvent(payload: Buffer): KvServerEvent | null { - for (const top of decodeFields(payload)) { - if (top.fieldNumber !== ASM_KV_SERVER_MESSAGE || top.wireType !== 2) continue; + const top = findField(decodeFields(payload), ASM_KV_SERVER_MESSAGE); + if (top?.wireType !== WT_LEN) return null; - let kvId = 0; - let getBlobArgs: Buffer | null = null; - let setBlobArgs: Buffer | null = null; - let requestMetadata: Buffer | null = null; - - for (const f of decodeFields(top.bytes)) { - if (f.fieldNumber === KSM_ID && f.wireType === 0) { - kvId = Number(f.varint); - } else if (f.fieldNumber === KSM_GET_BLOB_ARGS && f.wireType === 2) { - getBlobArgs = f.bytes; - } else if (f.fieldNumber === KSM_SET_BLOB_ARGS && f.wireType === 2) { - setBlobArgs = f.bytes; - } else if (f.fieldNumber === KSM_REQUEST_METADATA && f.wireType === 2) { - requestMetadata = f.bytes; - } - } - - if (getBlobArgs) { - // GetBlobArgs { blob_id (1): bytes } - let blobId: Buffer = Buffer.alloc(0); - for (const f of decodeFields(getBlobArgs)) { - if (f.fieldNumber === GBA_BLOB_ID && f.wireType === 2) { - blobId = f.bytes; - } - } - return { kind: "kv_get_blob", kvId, blobId, requestMetadata }; - } - if (setBlobArgs) { - // SetBlobArgs { blob_id (1): bytes, blob_data (2): bytes } - let blobId: Buffer = Buffer.alloc(0); - let blobData: Buffer = Buffer.alloc(0); - for (const f of decodeFields(setBlobArgs)) { - if (f.fieldNumber === SBA_BLOB_ID && f.wireType === 2) { - blobId = f.bytes; - } else if (f.fieldNumber === SBA_BLOB_DATA && f.wireType === 2) { - blobData = f.bytes; - } - } - return { kind: "kv_set_blob", kvId, blobId, blobData, requestMetadata }; - } + const fields = decodeFields(top.bytes); + const idField = findField(fields, KSM_ID); + const kvId = idField?.wireType === WT_VARINT ? Number(idField.varint) : 0; + const requestMetadata = findLengthDelimitedField(fields, KSM_REQUEST_METADATA); + const getBlobArgs = findLengthDelimitedField(fields, KSM_GET_BLOB_ARGS); + if (getBlobArgs) { + return { + kind: "kv_get_blob", + kvId, + blobId: decodeBlobId(getBlobArgs, GBA_BLOB_ID), + requestMetadata, + }; } - return null; + + const setBlobArgs = findLengthDelimitedField(fields, KSM_SET_BLOB_ARGS); + if (!setBlobArgs) return null; + return { kind: "kv_set_blob", kvId, ...decodeSetBlobArgs(setBlobArgs), requestMetadata }; } // ─── Phase 2: full ExecServerMessage variant decoder ─────────────────────── @@ -886,143 +880,121 @@ function decodeShellArgs(payload: Buffer): DecodedShellArgs { return decoded; } -export function decodeExecServerEvent(payload: Buffer): ExecServerEvent | null { - for (const top of decodeFields(payload)) { - if (top.fieldNumber !== ASM_EXEC_SERVER_MESSAGE || top.wireType !== 2) continue; +type ExecEventContext = { + execMsgId: number; + execId: string; + variantBytes: Buffer; +}; - let execMsgId = 0; - let execId = ""; - let variantField = 0; - let variantBytes: Buffer | null = null; +type ExecEventDecoder = (context: ExecEventContext) => ExecServerEvent; +type PathExecKind = "exec_read" | "exec_write" | "exec_delete" | "exec_ls"; +type ShellExecKind = "exec_shell" | "exec_shell_stream" | "exec_bg_shell"; - for (const f of decodeFields(top.bytes)) { - if (f.fieldNumber === ESM_ID && f.wireType === 0) { - execMsgId = Number(f.varint); - } else if (f.fieldNumber === ESM_EXEC_ID && f.wireType === 2) { - execId = f.bytes.toString("utf8"); - } else if (f.wireType === 2) { - // Any other LEN field is the variant payload. Take the first one we - // see — variants don't co-occur in a well-formed message. - if (variantField === 0) { - variantField = f.fieldNumber; - variantBytes = f.bytes; - } - } - } +function createPathExecEvent(kind: PathExecKind, context: ExecEventContext): ExecServerEvent { + return { + kind, + execMsgId: context.execMsgId, + execId: context.execId, + path: decodeStringField(context.variantBytes, ARG_PATH), + }; +} - if (variantBytes === null) continue; +function createShellExecEvent(kind: ShellExecKind, context: ExecEventContext): ExecServerEvent { + return { + kind, + execMsgId: context.execMsgId, + execId: context.execId, + ...decodeShellArgs(context.variantBytes), + }; +} - switch (variantField) { - case ESM_REQUEST_CONTEXT_ARGS: - return { kind: "exec_request_context", execMsgId, execId }; - case ESM_READ_ARGS: - return { - kind: "exec_read", - execMsgId, - execId, - path: decodeStringField(variantBytes, ARG_PATH), - }; - case ESM_WRITE_ARGS: - return { - kind: "exec_write", - execMsgId, - execId, - path: decodeStringField(variantBytes, ARG_PATH), - }; - case ESM_DELETE_ARGS: - return { - kind: "exec_delete", - execMsgId, - execId, - path: decodeStringField(variantBytes, ARG_PATH), - }; - case ESM_LS_ARGS: - return { - kind: "exec_ls", - execMsgId, - execId, - path: decodeStringField(variantBytes, ARG_PATH), - }; - case ESM_GREP_ARGS: - return { kind: "exec_grep", execMsgId, execId }; - case ESM_DIAGNOSTICS_ARGS: - return { kind: "exec_diagnostics", execMsgId, execId }; - case ESM_SHELL_ARGS: { - const shell = decodeShellArgs(variantBytes); - return { - kind: "exec_shell", - execMsgId, - execId, - ...shell, - }; - } - case ESM_SHELL_STREAM_ARGS: { - const shell = decodeShellArgs(variantBytes); - return { - kind: "exec_shell_stream", - execMsgId, - execId, - ...shell, - }; - } - case ESM_BACKGROUND_SHELL_SPAWN: { - const shell = decodeShellArgs(variantBytes); - return { - kind: "exec_bg_shell", - execMsgId, - execId, - ...shell, - }; - } - case ESM_FETCH_ARGS: - return { - kind: "exec_fetch", - execMsgId, - execId, - url: decodeStringField(variantBytes, ARG_FETCH_URL), - }; - case ESM_WRITE_SHELL_STDIN_ARGS: - return { kind: "exec_write_shell_stdin", execMsgId, execId }; - case ESM_MCP_ARGS: { - // McpArgs.args is map; each value is a protobuf- - // encoded google.protobuf.Value. Decode keys and value-bytes here, - // then convert each Value to its JSON shape. - let toolName = ""; - let toolCallId = ""; - const args: Record = {}; - for (const f of decodeFields(variantBytes)) { - if (f.wireType !== 2) continue; - if (f.fieldNumber === MCA_TOOL_NAME) { - toolName = f.bytes.toString("utf8"); - } else if (f.fieldNumber === MCA_NAME && !toolName) { - // tool_name (5) takes precedence; fall back to name (1) - toolName = f.bytes.toString("utf8"); - } else if (f.fieldNumber === MCA_TOOL_CALL_ID) { - toolCallId = f.bytes.toString("utf8"); - } else if (f.fieldNumber === MCA_ARGS) { - // FieldsEntry { key (1): string, value (2): bytes } - let key = ""; - let valueBytes: Buffer | null = null; - for (const entry of decodeFields(f.bytes)) { - if (entry.fieldNumber === MAP_KEY && entry.wireType === 2) { - key = entry.bytes.toString("utf8"); - } else if (entry.fieldNumber === MAP_VALUE && entry.wireType === 2) { - valueBytes = entry.bytes; - } - } - if (key && valueBytes !== null) { - args[key] = decodeProtobufValue(valueBytes); - } - } - } - return { kind: "exec_mcp", execMsgId, execId, toolName, toolCallId, args }; - } - default: - // Unknown variant — return null so caller can keep buffering. - return null; - } +function decodeMcpMapEntry(payload: Buffer): { key: string; value: unknown } | null { + const fields = decodeFields(payload); + const key = findLengthDelimitedField(fields, MAP_KEY)?.toString("utf8") ?? ""; + const valueBytes = findLengthDelimitedField(fields, MAP_VALUE); + return key && valueBytes ? { key, value: decodeProtobufValue(valueBytes) } : null; +} + +function decodeMcpExecEvent(context: ExecEventContext): ExecServerEvent { + const fields = decodeFields(context.variantBytes); + const canonicalName = findLengthDelimitedField(fields, MCA_TOOL_NAME); + const fallbackName = findLengthDelimitedField(fields, MCA_NAME); + const toolName = (canonicalName ?? fallbackName)?.toString("utf8") ?? ""; + const toolCallId = findLengthDelimitedField(fields, MCA_TOOL_CALL_ID)?.toString("utf8") ?? ""; + const args: Record = {}; + for (const field of fields) { + if (field.fieldNumber !== MCA_ARGS || field.wireType !== WT_LEN) continue; + const entry = decodeMcpMapEntry(field.bytes); + if (entry) args[entry.key] = entry.value; } - return null; + return { + kind: "exec_mcp", + execMsgId: context.execMsgId, + execId: context.execId, + toolName, + toolCallId, + args, + }; +} + +const EXEC_EVENT_DECODERS: Partial> = { + [ESM_REQUEST_CONTEXT_ARGS]: ({ execMsgId, execId }) => ({ + kind: "exec_request_context", + execMsgId, + execId, + }), + [ESM_READ_ARGS]: (context) => createPathExecEvent("exec_read", context), + [ESM_WRITE_ARGS]: (context) => createPathExecEvent("exec_write", context), + [ESM_DELETE_ARGS]: (context) => createPathExecEvent("exec_delete", context), + [ESM_LS_ARGS]: (context) => createPathExecEvent("exec_ls", context), + [ESM_GREP_ARGS]: ({ execMsgId, execId }) => ({ kind: "exec_grep", execMsgId, execId }), + [ESM_DIAGNOSTICS_ARGS]: ({ execMsgId, execId }) => ({ + kind: "exec_diagnostics", + execMsgId, + execId, + }), + [ESM_SHELL_ARGS]: (context) => createShellExecEvent("exec_shell", context), + [ESM_SHELL_STREAM_ARGS]: (context) => createShellExecEvent("exec_shell_stream", context), + [ESM_BACKGROUND_SHELL_SPAWN]: (context) => createShellExecEvent("exec_bg_shell", context), + [ESM_FETCH_ARGS]: ({ execMsgId, execId, variantBytes }) => ({ + kind: "exec_fetch", + execMsgId, + execId, + url: decodeStringField(variantBytes, ARG_FETCH_URL), + }), + [ESM_WRITE_SHELL_STDIN_ARGS]: ({ execMsgId, execId }) => ({ + kind: "exec_write_shell_stdin", + execMsgId, + execId, + }), + [ESM_MCP_ARGS]: decodeMcpExecEvent, +}; + +function decodeExecEventContext( + payload: Buffer +): (ExecEventContext & { variantField: number }) | null { + const top = findField(decodeFields(payload), ASM_EXEC_SERVER_MESSAGE); + if (top?.wireType !== WT_LEN) return null; + + const fields = decodeFields(top.bytes); + const idField = findField(fields, ESM_ID); + const variant = fields.find( + (field) => field.wireType === WT_LEN && field.fieldNumber !== ESM_EXEC_ID + ); + if (!variant || variant.wireType !== WT_LEN) return null; + return { + execMsgId: idField?.wireType === WT_VARINT ? Number(idField.varint) : 0, + execId: findLengthDelimitedField(fields, ESM_EXEC_ID)?.toString("utf8") ?? "", + variantField: variant.fieldNumber, + variantBytes: variant.bytes, + }; +} + +export function decodeExecServerEvent(payload: Buffer): ExecServerEvent | null { + const context = decodeExecEventContext(payload); + if (!context) return null; + const decoder = EXEC_EVENT_DECODERS[context.variantField]; + return decoder?.(context) ?? null; } /** @@ -1316,6 +1288,87 @@ export function jsonSchemaToProtobufValue(json: unknown): Buffer { * Handles all six Value variants: null, number (double), string, bool, * struct (object), list (array). Unknown fields are skipped. */ +type ProtobufValueDecodeResult = { value: unknown; nextPos: number }; +type ProtobufValueDecoder = ( + buf: Buffer, + pos: number, + wireType: number +) => ProtobufValueDecodeResult; + +function readLengthDelimitedPayload( + buf: Buffer, + pos: number, + wireType: number +): { payload: Buffer; nextPos: number } | null { + if (wireType !== WT_LEN) return null; + const [len, afterLength] = decodeVarint(buf, pos); + const lenN = checkedLen(len, afterLength, buf); + return { + payload: buf.subarray(afterLength, afterLength + lenN), + nextPos: afterLength + lenN, + }; +} + +function decodeNullValue(buf: Buffer, pos: number, wireType: number): ProtobufValueDecodeResult { + const nextPos = wireType === WT_VARINT ? decodeVarint(buf, pos)[1] : pos; + return { value: null, nextPos }; +} + +function decodeNumberValue(buf: Buffer, pos: number, wireType: number): ProtobufValueDecodeResult { + const valid = wireType === 1 && pos + 8 <= buf.length; + return { value: valid ? buf.readDoubleLE(pos) : 0, nextPos: valid ? pos + 8 : pos }; +} + +function decodeStringValue(buf: Buffer, pos: number, wireType: number): ProtobufValueDecodeResult { + const decoded = readLengthDelimitedPayload(buf, pos, wireType); + return { + value: decoded?.payload.toString("utf8") ?? "", + nextPos: decoded?.nextPos ?? pos, + }; +} + +function decodeBoolValue(buf: Buffer, pos: number, wireType: number): ProtobufValueDecodeResult { + if (wireType !== WT_VARINT) return { value: false, nextPos: pos }; + const [value, nextPos] = decodeVarint(buf, pos); + return { value: value !== 0n, nextPos }; +} + +function decodeStructValue(buf: Buffer, pos: number, wireType: number): ProtobufValueDecodeResult { + const decoded = readLengthDelimitedPayload(buf, pos, wireType); + return { + value: decoded ? decodeProtobufStruct(decoded.payload) : {}, + nextPos: decoded?.nextPos ?? pos, + }; +} + +function decodeListValue(buf: Buffer, pos: number, wireType: number): ProtobufValueDecodeResult { + const decoded = readLengthDelimitedPayload(buf, pos, wireType); + return { + value: decoded ? decodeProtobufList(decoded.payload) : [], + nextPos: decoded?.nextPos ?? pos, + }; +} + +const PROTOBUF_VALUE_DECODERS: Partial> = { + [VAL_NULL]: decodeNullValue, + [VAL_NUMBER]: decodeNumberValue, + [VAL_STRING]: decodeStringValue, + [VAL_BOOL]: decodeBoolValue, + [VAL_STRUCT]: decodeStructValue, + [VAL_LIST]: decodeListValue, +}; + +function skipUnknownProtobufField(buf: Buffer, pos: number, wireType: number): number { + if (wireType === WT_VARINT) return decodeVarint(buf, pos)[1]; + if (wireType === WT_LEN) { + const [len, afterLength] = decodeVarint(buf, pos); + return afterLength + checkedLen(len, afterLength, buf); + } + if (wireType === 1) return pos + 8; + if (wireType === 5) return pos + 4; + return pos; +} + export function decodeProtobufValue(buf: Buffer): unknown { let pos = 0; while (pos < buf.length) { @@ -1323,97 +1376,26 @@ export function decodeProtobufValue(buf: Buffer): unknown { pos = np; const fieldNumber = Number(t >> 3n); const wireType = Number(t & 0x7n); - switch (fieldNumber) { - case VAL_NULL: { - if (wireType === WT_VARINT) { - [, pos] = decodeVarint(buf, pos); - } - return null; - } - case VAL_NUMBER: { - if (wireType === 1 && pos + 8 <= buf.length) { - const value = buf.readDoubleLE(pos); - pos += 8; - return value; - } - return 0; - } - case VAL_STRING: { - if (wireType === WT_LEN) { - const [len, np2] = decodeVarint(buf, pos); - pos = np2; - const lenN = checkedLen(len, pos, buf); - const value = buf.subarray(pos, pos + lenN).toString("utf8"); - pos += lenN; - return value; - } - return ""; - } - case VAL_BOOL: { - if (wireType === WT_VARINT) { - const [val, np2] = decodeVarint(buf, pos); - pos = np2; - return val !== 0n; - } - return false; - } - case VAL_STRUCT: { - if (wireType === WT_LEN) { - const [len, np2] = decodeVarint(buf, pos); - pos = np2; - const lenN = checkedLen(len, pos, buf); - const inner = buf.subarray(pos, pos + lenN); - pos += lenN; - return decodeProtobufStruct(inner); - } - return {}; - } - case VAL_LIST: { - if (wireType === WT_LEN) { - const [len, np2] = decodeVarint(buf, pos); - pos = np2; - const lenN = checkedLen(len, pos, buf); - const inner = buf.subarray(pos, pos + lenN); - pos += lenN; - return decodeProtobufList(inner); - } - return []; - } - default: - // Skip unknown field - if (wireType === WT_VARINT) { - [, pos] = decodeVarint(buf, pos); - } else if (wireType === WT_LEN) { - const [len, np2] = decodeVarint(buf, pos); - pos = np2; - pos += Number(len); - } else if (wireType === 1) { - pos += 8; - } else if (wireType === 5) { - pos += 4; - } - } + const decoder = PROTOBUF_VALUE_DECODERS[fieldNumber]; + if (decoder) return decoder(buf, pos, wireType).value; + pos = skipUnknownProtobufField(buf, pos, wireType); } return null; } +function decodeProtobufStructEntry(payload: Buffer): { key: string; value: unknown } | null { + const fields = decodeFields(payload); + const key = findLengthDelimitedField(fields, MAP_KEY)?.toString("utf8") ?? ""; + const valueBytes = findLengthDelimitedField(fields, MAP_VALUE); + return key && valueBytes ? { key, value: decodeProtobufValue(valueBytes) } : null; +} + function decodeProtobufStruct(buf: Buffer): Record { const result: Record = {}; - for (const f of decodeFields(buf)) { - if (f.fieldNumber === STRUCT_FIELDS && f.wireType === 2) { - let key = ""; - let valueBytes: Buffer | null = null; - for (const entry of decodeFields(f.bytes)) { - if (entry.fieldNumber === MAP_KEY && entry.wireType === 2) { - key = entry.bytes.toString("utf8"); - } else if (entry.fieldNumber === MAP_VALUE && entry.wireType === 2) { - valueBytes = entry.bytes; - } - } - if (key && valueBytes) { - result[key] = decodeProtobufValue(valueBytes); - } - } + for (const field of decodeFields(buf)) { + if (field.fieldNumber !== STRUCT_FIELDS || field.wireType !== WT_LEN) continue; + const entry = decodeProtobufStructEntry(field.bytes); + if (entry) result[entry.key] = entry.value; } return result; } @@ -1477,6 +1459,39 @@ export type ChatMessage = { tool_call_id?: string; }; +function messageContentToText(content: ChatMessage["content"]): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((part) => (typeof part?.text === "string" ? part.text : "")) + .filter(Boolean) + .join("\n"); +} + +function assistantMessageLines(message: ChatMessage, text: string): string[] { + const lines = text ? [`Assistant: ${text}`] : []; + for (const toolCall of message.tool_calls ?? []) { + const name = toolCall.function?.name ?? "(unknown)"; + const args = toolCall.function?.arguments ?? ""; + lines.push(`Assistant called tool ${name} (${toolCall.id}) with arguments: ${args}`); + } + return lines; +} + +function chatMessageLines(message: ChatMessage): string[] { + const text = messageContentToText(message.content); + if (message.role === "user") return text ? [`User: ${text}`] : []; + if (message.role === "assistant") return assistantMessageLines(message, text); + if (message.role === "tool") { + return [`Tool result (${message.tool_call_id ?? "(unknown)"}): ${text}`]; + } + return text ? [`${message.role}: ${text}`] : []; +} + +function joinSystemText(systemTexts: string[], body: string): string { + return systemTexts.length > 0 ? `${systemTexts.join("\n\n")}\n\n${body}` : body; +} + /** * Flatten an OpenAI-shaped message list down to a single user-text string * suitable for cursor's UserMessage. The agent endpoint expects ONE user @@ -1490,57 +1505,23 @@ export type ChatMessage = { export function flattenMessages(messages: ChatMessage[]): string { if (!Array.isArray(messages) || messages.length === 0) return ""; - const partsToText = (content: ChatMessage["content"]): string => { - if (typeof content === "string") return content; - if (content == null) return ""; - if (!Array.isArray(content)) return ""; - return content - .map((p) => (typeof p?.text === "string" ? p.text : "")) - .filter(Boolean) - .join("\n"); - }; - // System instructions go first as a labeled prefix. (The cursor executor // routes system messages through the KV blob channel — see Phase 7 — but // this branch is kept for non-cursor callers.) const systemTexts = messages .filter((m) => m.role === "system") - .map((m) => partsToText(m.content)) + .map((m) => messageContentToText(m.content)) .filter(Boolean); const turn = messages.filter((m) => m.role !== "system"); // Single-user-message fast path (no tool_calls, no labels). if (turn.length === 1 && turn[0].role === "user" && !turn[0].tool_calls) { - const userText = partsToText(turn[0].content); - return systemTexts.length > 0 ? `${systemTexts.join("\n\n")}\n\n${userText}` : userText; + return joinSystemText(systemTexts, messageContentToText(turn[0].content)); } // Multi-turn / tool-using format. Each message is labeled. Tool calls // and tool results get their own labeled lines. - const lines: string[] = []; - for (const m of turn) { - const text = partsToText(m.content); - if (m.role === "user") { - if (text) lines.push(`User: ${text}`); - } else if (m.role === "assistant") { - if (text) lines.push(`Assistant: ${text}`); - if (Array.isArray(m.tool_calls)) { - for (const tc of m.tool_calls) { - const args = tc.function?.arguments ?? ""; - lines.push( - `Assistant called tool ${tc.function?.name ?? "(unknown)"} ` + - `(${tc.id}) with arguments: ${args}` - ); - } - } - } else if (m.role === "tool") { - const callId = m.tool_call_id ?? "(unknown)"; - lines.push(`Tool result (${callId}): ${text}`); - } else { - if (text) lines.push(`${m.role}: ${text}`); - } - } - const labelled = lines.join("\n\n"); - return systemTexts.length > 0 ? `${systemTexts.join("\n\n")}\n\n${labelled}` : labelled; + const labelled = turn.flatMap(chatMessageLines).join("\n\n"); + return joinSystemText(systemTexts, labelled); } diff --git a/open-sse/utils/cursorAgentProtobuf/requestedModelParameters.ts b/open-sse/utils/cursorAgentProtobuf/requestedModelParameters.ts new file mode 100644 index 0000000000..da431746eb --- /dev/null +++ b/open-sse/utils/cursorAgentProtobuf/requestedModelParameters.ts @@ -0,0 +1,113 @@ +export const CURSOR_EFFORT_SUFFIXES = ["low", "medium", "high", "xhigh", "max"] as const; + +type CursorRequestedModel = { + modelId: string; + parameters: Array<{ id: string; value: string }>; +}; + +const CURSOR_ONE_MILLION_SUFFIX = "-1m"; +const CURSOR_GPT_REASONING_LEVELS = ["none", ...CURSOR_EFFORT_SUFFIXES] as const; + +const CURSOR_CLAUDE_ONE_MILLION_FAMILIES = [ + { + legacyPrefix: "claude-fable-5-1", + modelId: "claude-fable-5-1", + supportsFast: false, + trailingThinking: false, + }, + { + legacyPrefix: "claude-opus-5", + modelId: "claude-opus-5", + supportsFast: true, + trailingThinking: false, + }, + { + legacyPrefix: "claude-opus-4-8", + modelId: "claude-opus-4-8", + supportsFast: true, + trailingThinking: false, + }, + { + legacyPrefix: "claude-sonnet-5", + modelId: "claude-sonnet-5", + supportsFast: false, + trailingThinking: false, + }, + { + legacyPrefix: "claude-4.6-sonnet", + modelId: "claude-sonnet-4-6", + supportsFast: false, + trailingThinking: true, + }, +] as const; + +type CursorClaudeOneMillionFamily = (typeof CURSOR_CLAUDE_ONE_MILLION_FAMILIES)[number]; + +function isCursorEffort(value: string): value is (typeof CURSOR_EFFORT_SUFFIXES)[number] { + return CURSOR_EFFORT_SUFFIXES.some((effort) => effort === value); +} + +function resolveGptOneMillionContextModel(legacyId: string): CursorRequestedModel | null { + const match = /^(gpt-5\.6-(?:sol|terra|luna))-(none|low|medium|high|xhigh|max)$/.exec(legacyId); + if (!match) return null; + + const [, modelId, reasoning] = match; + if (!CURSOR_GPT_REASONING_LEVELS.some((level) => level === reasoning)) return null; + return { + modelId, + parameters: [ + { id: "context", value: "1m" }, + { id: "reasoning", value: reasoning }, + { id: "fast", value: "false" }, + ], + }; +} + +function resolveClaudeOneMillionVariant( + legacyId: string, + family: CursorClaudeOneMillionFamily +): CursorRequestedModel | null { + const prefix = `${family.legacyPrefix}-`; + if (!legacyId.startsWith(prefix)) return null; + + let variant = legacyId.slice(prefix.length); + const fast = variant.endsWith("-fast"); + if (fast) variant = variant.slice(0, -"-fast".length); + if (fast && !family.supportsFast) return null; + + const trailingThinking = family.trailingThinking && variant.endsWith("-thinking"); + const leadingThinking = !family.trailingThinking && variant.startsWith("thinking-"); + if (trailingThinking) variant = variant.slice(0, -"-thinking".length); + if (leadingThinking) variant = variant.slice("thinking-".length); + if (!isCursorEffort(variant)) return null; + + const parameters = [ + { id: "thinking", value: String(trailingThinking || leadingThinking) }, + { id: "context", value: "1m" }, + { id: "effort", value: variant }, + ]; + if (family.supportsFast) parameters.push({ id: "fast", value: String(fast) }); + return { modelId: family.modelId, parameters }; +} + +function resolveClaudeOneMillionContextModel(legacyId: string): CursorRequestedModel | null { + for (const family of CURSOR_CLAUDE_ONE_MILLION_FAMILIES) { + const resolved = resolveClaudeOneMillionVariant(legacyId, family); + if (resolved) return resolved; + } + return null; +} + +/** + * Cursor reuses each legacy slug for both its default and 1M context variants, + * so the public catalog adds a terminal `-1m` discriminator. Translate that + * synthetic id to the canonical wire model plus the complete parameter set + * reported by Cursor's AvailableModels metadata. + */ +export function resolveOneMillionContextModel(normalized: string): CursorRequestedModel | null { + if (!normalized.endsWith(CURSOR_ONE_MILLION_SUFFIX)) return null; + const legacyId = normalized.slice(0, -CURSOR_ONE_MILLION_SUFFIX.length); + return ( + resolveGptOneMillionContextModel(legacyId) ?? resolveClaudeOneMillionContextModel(legacyId) + ); +} diff --git a/open-sse/utils/registeredEffortVariants.ts b/open-sse/utils/registeredEffortVariants.ts index 06e2d5dfc6..2cef8d6721 100644 --- a/open-sse/utils/registeredEffortVariants.ts +++ b/open-sse/utils/registeredEffortVariants.ts @@ -14,10 +14,9 @@ export function getRegisteredProviderEffortBaseModelId( modelId: string ): string | null { const providerModels = getProviderModels(providerId); + const registeredVariant = providerModels.find((candidate) => candidate.id === modelId); - if (!providerModels.some((candidate) => candidate.id === modelId)) { - return null; - } + if (!registeredVariant) return null; for (const effort of REGISTERED_EFFORT_SUFFIXES) { const suffix = `-${effort}`; @@ -25,7 +24,15 @@ export function getRegisteredProviderEffortBaseModelId( const baseModelId = modelId.slice(0, -suffix.length); - return providerModels.some((candidate) => candidate.id === baseModelId) ? baseModelId : null; + if (providerModels.some((candidate) => candidate.id === baseModelId)) return baseModelId; + + // Curated providers may intentionally expose only useful variants while the + // authoritative live catalog exposes their unsuffixed wire model. The registry + // declaration is the proof; never infer this relationship from spelling alone. + const declaredLiveBase = registeredVariant.liveCatalogIds?.find( + (candidate) => candidate === baseModelId || !candidate.endsWith(`-${effort}`) + ); + return declaredLiveBase ?? null; } return null; diff --git a/src/lib/db/models/activeSyncedCatalog.ts b/src/lib/db/models/activeSyncedCatalog.ts index 981175219f..18ade8260b 100644 --- a/src/lib/db/models/activeSyncedCatalog.ts +++ b/src/lib/db/models/activeSyncedCatalog.ts @@ -1,5 +1,6 @@ import { providerUsesAuthoritativeLiveCatalog } from "@omniroute/open-sse/config/providerRegistry"; import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts"; +import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog"; import { getSyncedAvailableModels, getSyncedAvailableModelsByConnection, @@ -76,6 +77,19 @@ function collectModelsForConnections( return Array.from(models.values()); } +function enrichCursorCatalog( + providerId: string, + models: SyncedAvailableModel[] +): SyncedAvailableModel[] { + // An empty sync means discovery has not completed (or failed). Do not let the + // synthetic Cursor auto-router rows turn that empty state into an authoritative + // catalog, otherwise every built-in model is incorrectly marked unavailable. + if (models.length === 0) return models; + return providerId === "cursor" || providerId === "cursor-api" + ? ensureCursorAutoCatalogEntry(models) + : models; +} + /** * Return the unioned synced catalog belonging only to active connections. * @@ -105,7 +119,10 @@ export async function getActiveSyncedCatalog(providerId: string): Promise connection !== null) .map((connection) => connection.id); - const models = collectModelsForConnections(modelsByConnection, activeConnectionIds); + const models = enrichCursorCatalog( + storedProviderId, + collectModelsForConnections(modelsByConnection, activeConnectionIds) + ); if (models.length > 0) { return { authoritative: providerUsesAuthoritativeLiveCatalog(providerId), @@ -125,7 +142,13 @@ export async function getActiveSyncedCatalog(providerId: string): Promise { const modelsByConnection = await getSyncedAvailableModelsByConnection(providerId); - const models = collectModelsForConnections(modelsByConnection, connectionIds); + const models = enrichCursorCatalog( + providerId, + collectModelsForConnections(modelsByConnection, connectionIds) + ); if (models.length > 0) { result[providerId] = models; diff --git a/src/lib/providerModels/cursorAutoCatalog.ts b/src/lib/providerModels/cursorAutoCatalog.ts index a9c0f2dca2..ddea216560 100644 --- a/src/lib/providerModels/cursorAutoCatalog.ts +++ b/src/lib/providerModels/cursorAutoCatalog.ts @@ -8,7 +8,6 @@ export type CursorAutoCatalogEntry = { id: string; name: string; owned_by?: string; - [key: string]: unknown; }; export const CURSOR_AUTO_ROUTER_VARIANT_IDS = [ @@ -26,10 +25,56 @@ const CURSOR_AUTO_ROUTER_VARIANT_NAMES: Record< "auto-intelligence": "Auto (intelligence)", }; +const CURSOR_ONE_MILLION_CONTEXT = 1_000_000; +const CURSOR_CONTEXT_EFFORT = "(?:low|medium|high|xhigh|max)"; +const CURSOR_ONE_MILLION_MODEL_PATTERNS = [ + new RegExp(`^claude-fable-5-1-thinking-${CURSOR_CONTEXT_EFFORT}$`), + new RegExp(`^claude-opus-5-(?:thinking-)?${CURSOR_CONTEXT_EFFORT}(?:-fast)?$`), + new RegExp(`^claude-opus-4-8-(?:thinking-)?${CURSOR_CONTEXT_EFFORT}(?:-fast)?$`), + new RegExp(`^claude-sonnet-5-(?:thinking-)?${CURSOR_CONTEXT_EFFORT}$`), + new RegExp(`^claude-4\\.6-sonnet-${CURSOR_CONTEXT_EFFORT}(?:-thinking)?$`), + new RegExp(`^gpt-5\\.6-(?:sol|terra|luna)-(?:none|${CURSOR_CONTEXT_EFFORT})$`), +] as const; + +const CURSOR_CONTEXT_FAMILY_NAMES = [ + "Claude Fable 5.1", + "Claude Opus 5", + "Claude Opus 4.8", + "Claude Sonnet 5", + "Claude Sonnet 4.6", + "GPT-5.6 Sol", + "GPT-5.6 Terra", + "GPT-5.6 Luna", +] as const; + +function supportsCursorOneMillionContext(id: string): boolean { + return CURSOR_ONE_MILLION_MODEL_PATTERNS.some((pattern) => pattern.test(id)); +} + +function oneMillionDisplayName(name: string): string { + const family = CURSOR_CONTEXT_FAMILY_NAMES.find((candidate) => name.startsWith(candidate)); + return family ? `${family} 1M${name.slice(family.length)}` : `${name} 1M`; +} + /** Cursor auto-router: catalog id `auto`, wire id `default`. Always keep `auto` visible. */ export function ensureCursorAutoCatalogEntry(models: T[]): T[] { const byId = new Map(models.map((m) => [m.id, m])); - const out = [...models]; + const out: T[] = []; + + for (const model of models) { + const oneMillionId = `${model.id}-1m`; + if (supportsCursorOneMillionContext(model.id) && !byId.has(oneMillionId)) { + const oneMillionEntry = { + ...model, + id: oneMillionId, + name: oneMillionDisplayName(model.name), + contextLength: CURSOR_ONE_MILLION_CONTEXT, + } as T; + out.push(oneMillionEntry); + byId.set(oneMillionId, oneMillionEntry); + } + out.push(model); + } if (!byId.has("auto")) { const defaultEntry = byId.get("default"); diff --git a/src/lib/providerModels/cursorAvailableModels.ts b/src/lib/providerModels/cursorAvailableModels.ts index 0fdc3d1939..8c4eb7b04a 100644 --- a/src/lib/providerModels/cursorAvailableModels.ts +++ b/src/lib/providerModels/cursorAvailableModels.ts @@ -9,8 +9,11 @@ import { humanizeCursorModelId, type CursorAgentModelEntry, } from "@/lib/providerModels/cursorAgent"; +import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog"; import { getConsistentMachineId } from "@/shared/utils/machineId"; +export { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog"; + export type FetchCursorAvailableModelsOptions = { accessToken: string; machineId?: string | null; @@ -40,6 +43,45 @@ function pickModelName(entry: Record, id: string): string { return humanizeCursorModelId(id); } +function collectArrays(record: Record, keys: string[]): unknown[] { + return keys.flatMap((key) => (Array.isArray(record[key]) ? record[key] : [])); +} + +function collectModelCandidates(payload: unknown): unknown[] { + const root = asRecord(payload) ?? {}; + const candidates = collectArrays(root, [ + "models", + "availableModels", + "available_models", + "model", + ]); + const nestedModels = asRecord(root.models); + if (nestedModels) candidates.push(...collectArrays(nestedModels, ["models", "items", "list"])); + if (Array.isArray(payload)) candidates.push(...payload); + return candidates; +} + +function isUnavailableModel(entry: Record): boolean { + return ( + entry.disabled === true || + entry.isDisabled === true || + entry.usable === false || + entry.isUsable === false + ); +} + +function normalizeModelCandidate(item: unknown): CursorAgentModelEntry | null { + if (typeof item === "string") { + const id = item.trim(); + return id ? { id, name: humanizeCursorModelId(id), owned_by: "cursor" } : null; + } + + const entry = asRecord(item); + if (!entry || isUnavailableModel(entry)) return null; + const id = pickModelId(entry); + return id ? { id, name: pickModelName(entry, id), owned_by: "cursor" } : null; +} + /** * Normalize AvailableModels JSON (Connect JSON or protobuf-json) into catalog rows. * Exported for unit tests. @@ -48,99 +90,18 @@ function pickModelName(entry: Record, id: string): string { * only). OmniRoute clients request `cu/auto`; resolveRequestedModel maps it to `default`. */ export function normalizeCursorAvailableModelsPayload(payload: unknown): CursorAgentModelEntry[] { - const root = asRecord(payload) ?? {}; - const candidates: unknown[] = []; - - for (const key of ["models", "availableModels", "available_models", "model"]) { - const v = root[key]; - if (Array.isArray(v)) candidates.push(...v); - } - - // Some Connect JSON responses nest under `models.models` or similar - const nestedModels = asRecord(root.models); - if (nestedModels) { - for (const key of ["models", "items", "list"]) { - const v = nestedModels[key]; - if (Array.isArray(v)) candidates.push(...v); - } - } - - if (Array.isArray(payload)) candidates.push(...payload); - const seen = new Set(); const out: CursorAgentModelEntry[] = []; - for (const item of candidates) { - if (typeof item === "string" && item.trim()) { - const id = item.trim(); - if (seen.has(id)) continue; - seen.add(id); - out.push({ id, name: humanizeCursorModelId(id), owned_by: "cursor" }); - continue; - } - const rec = asRecord(item); - if (!rec) continue; - const id = pickModelId(rec); - if (!id || seen.has(id)) continue; - // Prefer usable / non-disabled when flags exist - if (rec.disabled === true || rec.isDisabled === true) continue; - if (rec.usable === false || rec.isUsable === false) continue; - seen.add(id); - out.push({ id, name: pickModelName(rec, id), owned_by: "cursor" }); + for (const item of collectModelCandidates(payload)) { + const model = normalizeModelCandidate(item); + if (!model || seen.has(model.id)) continue; + seen.add(model.id); + out.push(model); } return ensureCursorAutoCatalogEntry(out); } -/** OpenCodex-style Cursor Router optimization modes (catalog ids). */ -export const CURSOR_AUTO_ROUTER_VARIANT_IDS = [ - "auto-cost", - "auto-balance", - "auto-intelligence", -] as const; - -const CURSOR_AUTO_ROUTER_VARIANT_NAMES: Record< - (typeof CURSOR_AUTO_ROUTER_VARIANT_IDS)[number], - string -> = { - "auto-cost": "Auto (cost)", - "auto-balance": "Auto (balance)", - "auto-intelligence": "Auto (intelligence)", -}; - -/** Cursor auto-router: catalog id `auto`, wire id `default`. Always keep `auto` visible. */ -export function ensureCursorAutoCatalogEntry( - models: CursorAgentModelEntry[] -): CursorAgentModelEntry[] { - const byId = new Map(models.map((m) => [m.id, m])); - const out = [...models]; - - if (!byId.has("auto")) { - const defaultEntry = byId.get("default"); - const autoEntry: CursorAgentModelEntry = { - id: "auto", - name: defaultEntry?.name || "Auto (current, default)", - owned_by: "cursor", - }; - // Prefer `auto` as the public id; keep `default` for wire-compat listings. - out.unshift(autoEntry); - byId.set("auto", autoEntry); - } - - // Always expose Cost/Balance/Intelligence router modes (OpenCodex CURSOR_ROUTER_MODEL_IDS). - for (const id of CURSOR_AUTO_ROUTER_VARIANT_IDS) { - if (byId.has(id)) continue; - const entry: CursorAgentModelEntry = { - id, - name: CURSOR_AUTO_ROUTER_VARIANT_NAMES[id], - owned_by: "cursor", - }; - out.push(entry); - byId.set(id, entry); - } - - return out; -} - export async function fetchCursorAvailableModels( options: FetchCursorAvailableModelsOptions ): Promise { diff --git a/src/lib/providers/staticModels.ts b/src/lib/providers/staticModels.ts index 63eebdce28..5bb6598db5 100644 --- a/src/lib/providers/staticModels.ts +++ b/src/lib/providers/staticModels.ts @@ -35,6 +35,7 @@ const STATIC_MODEL_PROVIDERS: Record Array<{ id: string; name: str ], antigravity: () => ANTIGRAVITY_PUBLIC_MODELS.map((model) => ({ ...model })), claude: () => [ + { id: "claude-fable-5-1", name: "Claude Fable 5.1" }, { id: "claude-fable-5", name: "Claude Fable 5" }, { id: "claude-opus-5", name: "Claude Opus 5" }, { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index 95d956356b..01401ea986 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -45,7 +45,7 @@ export const CLI_TOOLS: Record = { name: "Claude Fable", alias: "fable", envKey: "ANTHROPIC_DEFAULT_FABLE_MODEL", - defaultValue: _cc.fable ? `cc/${_cc.fable}` : "cc/claude-fable-5", + defaultValue: _cc.fable ? `cc/${_cc.fable}` : "cc/claude-fable-5-1", isTopLevel: true, }, { diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index fcc96c8e9b..c8b6faaf4b 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -24,9 +24,13 @@ export interface ModelSpec { // Model ONLY supports adaptive thinking: manual extended thinking was removed. Sending // `thinking.type:"enabled"` or any `thinking.budget_tokens` returns HTTP 400; reasoning // is steered exclusively by `output_config.effort` (low/medium/high/xhigh/max). True for - // Claude Opus 4.7 and later (Opus 4.7/4.8/5, Fable 5). Per Anthropic's migration guide, + // Claude Opus 4.7 and later (Opus 4.7/4.8/5, Fable 5/5.1). Per Anthropic's migration guide, // any request that tries to set a fixed thinking budget gets a 400 error. adaptiveThinkingOnly?: boolean; + // The model rejects tool_choice values that require a tool call. Keep tools available, + // but normalize a forced choice to the default auto behavior before dispatch. Fable 5.1 always runs + // adaptive thinking, so forced tool use cannot be combined with any valid request. + rejectsForcedToolChoice?: boolean; // Highest effort accepted while `thinking.type:"disabled"` is present. Claude Opus 5 // rejects disabled thinking with xhigh/max, while accepting it through high. maxEffortWhenThinkingDisabled?: "high"; @@ -371,6 +375,21 @@ export const MODEL_SPECS: Record = { aliases: BEDROCK_CLAUDE_ALIASES("claude-opus-4-7", "claude-opus-4.7"), }, + // ── Claude Fable 5.1 ──────────────────────────────────────────── + "claude-fable-5-1": { + maxOutputTokens: 128000, + contextWindow: 1000000, + defaultThinkingBudget: 32000, + thinkingBudgetCap: 120000, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + rejectsThinkingDisabled: true, + adaptiveThinkingOnly: true, + rejectsForcedToolChoice: true, + aliases: BEDROCK_CLAUDE_ALIASES("claude-fable-5-1"), + }, + // ── Claude Fable 5 ────────────────────────────────────────────── "claude-fable-5": { maxOutputTokens: 128000, @@ -849,9 +868,39 @@ export function normalizeThinkingForModel>( getModelSpec(modelId)?.rejectsThinkingDisabled ) { const { thinking: _omitted, ...rest } = body as Record; - return rest as T; + return normalizeForcedToolChoiceForModel(rest as T, modelId); } - return body; + return normalizeForcedToolChoiceForModel(body, modelId); +} + +/** + * Normalize tool-choice constraints that a resolved model cannot accept. + * + * Claude Fable 5.1 always uses adaptive thinking and rejects tool choices that force + * either any tool or one named tool. Preserve the declared tools and every unrelated + * request field, but drop the choice to select the default `auto` behavior so routing a + * request to Fable 5.1 does not turn a recoverable preference into an upstream 400. + */ +export function normalizeForcedToolChoiceForModel>( + body: T, + modelId: string +): T { + if (!getModelSpec(modelId)?.rejectsForcedToolChoice) return body; + + const toolChoice = body.tool_choice; + const forced = + toolChoice === "required" || + toolChoice === "any" || + (toolChoice !== null && + typeof toolChoice === "object" && + !Array.isArray(toolChoice) && + ["any", "tool", "function"].includes( + String((toolChoice as Record).type || "").toLowerCase() + )); + if (!forced) return body; + + const { tool_choice: _omitted, ...rest } = body; + return rest as T; } export function capMaxOutputTokens(modelId: string, requested?: number): number | undefined { diff --git a/src/shared/constants/pricing/default-pricing.ts b/src/shared/constants/pricing/default-pricing.ts index 08b84a86e9..a36654bd06 100644 --- a/src/shared/constants/pricing/default-pricing.ts +++ b/src/shared/constants/pricing/default-pricing.ts @@ -7,10 +7,12 @@ import { DEFAULT_PRICING_OAUTH } from "./oauth-subscriptions"; import { DEFAULT_PRICING_FRONTIER } from "./frontier-labs"; import { DEFAULT_PRICING_INFERENCE } from "./inference-hosts"; import { DEFAULT_PRICING_REGIONAL } from "./regional"; +import { DEFAULT_PRICING_DEVIN } from "./devin"; export const DEFAULT_PRICING = { ...DEFAULT_PRICING_OAUTH, ...DEFAULT_PRICING_FRONTIER, ...DEFAULT_PRICING_INFERENCE, ...DEFAULT_PRICING_REGIONAL, + ...DEFAULT_PRICING_DEVIN, }; diff --git a/src/shared/constants/pricing/devin.ts b/src/shared/constants/pricing/devin.ts new file mode 100644 index 0000000000..36a0e49b3a --- /dev/null +++ b/src/shared/constants/pricing/devin.ts @@ -0,0 +1,142 @@ +type DevinTokenPricing = { + input: number; + cached: number; + output: number; +}; + +const QUALITY_EFFORTS = ["max", "xhigh", "high", "medium", "low"] as const; +const GPT_EFFORTS = ["max", "xhigh", "high", "medium", "low", "none"] as const; + +function variantIds(base: string, efforts: readonly string[]): string[] { + return efforts.map((effort) => `${base}-${effort}`); +} + +function fastVariantIds(base: string): string[] { + return QUALITY_EFFORTS.map((effort) => `${base}-${effort}-fast`); +} + +function priorityVariantIds(base: string): string[] { + return GPT_EFFORTS.map((effort) => `${base}-${effort}-priority`); +} + +function priced(ids: readonly string[], pricing: DevinTokenPricing) { + return Object.fromEntries(ids.map((id) => [id, pricing])); +} + +const CLAUDE_FABLE_5_1 = { input: 10, cached: 0.25, output: 50 }; +const CLAUDE_OPUS = { input: 5, cached: 0.5, output: 25 }; +const CLAUDE_OPUS_FAST = { input: 10, cached: 1, output: 50 }; +const CLAUDE_SONNET_5 = { input: 2, cached: 0.2, output: 10 }; +const CLAUDE_SONNET_4_6 = { input: 3, cached: 0.3, output: 15 }; +const CLAUDE_HAIKU_4_5 = { input: 1, cached: 0.1, output: 5 }; + +const GPT_5_6_SOL = { input: 4, cached: 0.4, output: 20 }; +const GPT_5_6_SOL_FAST = { input: 8, cached: 0.8, output: 40 }; +const GPT_5_6_TERRA = { input: 2, cached: 0.2, output: 12 }; +const GPT_5_6_TERRA_FAST = { input: 4, cached: 0.4, output: 24 }; +const GPT_5_6_LUNA = { input: 0.2, cached: 0.02, output: 1.2 }; +const GPT_5_6_LUNA_FAST = { input: 0.4, cached: 0.04, output: 2.4 }; + +/** + * Exact per-UID rates returned by Devin's authenticated live catalog on + * 2026-09-02. Rates are USD per one million tokens. + */ +export const DEVIN_MODEL_PRICING: Record = { + ...priced(variantIds("claude-fable-5-1", QUALITY_EFFORTS), CLAUDE_FABLE_5_1), + ...priced(variantIds("claude-opus-5", QUALITY_EFFORTS), CLAUDE_OPUS), + ...priced(fastVariantIds("claude-opus-5"), CLAUDE_OPUS_FAST), + ...priced(variantIds("claude-opus-4-8", QUALITY_EFFORTS), CLAUDE_OPUS), + ...priced(fastVariantIds("claude-opus-4-8"), CLAUDE_OPUS_FAST), + ...priced(variantIds("claude-sonnet-5", QUALITY_EFFORTS), CLAUDE_SONNET_5), + ...priced( + [ + "claude-sonnet-4-6", + "claude-sonnet-4-6-thinking", + "claude-sonnet-4-6-1m", + "claude-sonnet-4-6-thinking-1m", + ], + CLAUDE_SONNET_4_6 + ), + MODEL_PRIVATE_11: CLAUDE_HAIKU_4_5, + + ...priced(variantIds("gpt-5-6-sol", GPT_EFFORTS), GPT_5_6_SOL), + ...priced(priorityVariantIds("gpt-5-6-sol"), GPT_5_6_SOL_FAST), + ...priced(variantIds("gpt-5-6-terra", GPT_EFFORTS), GPT_5_6_TERRA), + ...priced(priorityVariantIds("gpt-5-6-terra"), GPT_5_6_TERRA_FAST), + ...priced(variantIds("gpt-5-6-luna", GPT_EFFORTS), GPT_5_6_LUNA), + ...priced(priorityVariantIds("gpt-5-6-luna"), GPT_5_6_LUNA_FAST), + + ...priced(variantIds("kimi-k3", ["max", "high", "low"]), { + input: 3, + cached: 0.3, + output: 15, + }), + "kimi-k2-7": { input: 0.95, cached: 0.19, output: 4 }, + ...priced(variantIds("glm-5-3", ["max", "high", "low"]), { + input: 1.4, + cached: 0.26, + output: 4.4, + }), + ...priced(variantIds("glm-5-3-flash", ["max", "high", "low"]), { + input: 0.15, + cached: 0.03, + output: 0.5, + }), + + ...priced(["swe-1-7", "swe-1-7-medium"], { + input: 0.5, + cached: 0.2, + output: 2.5, + }), + ...priced(["swe-1-7-lightning", "swe-1-7-lightning-medium"], { + input: 2.5, + cached: 1, + output: 12.5, + }), + adaptive: { input: 0.5, cached: 0.1, output: 2 }, + ...priced(variantIds("grok-4-6", ["xhigh", "high", "medium", "low"]), { + input: 2, + cached: 0.3, + output: 6, + }), + ...priced(variantIds("inkling", ["max", "xhigh", "high", "medium", "low", "none"]), { + input: 1.4, + cached: 0.26, + output: 4.4, + }), + ...priced(variantIds("deepseek-v4-flash", ["max", "high", "low"]), { + input: 0.14, + cached: 0.03, + output: 0.28, + }), + ...priced(variantIds("nemotron-3-ultra", ["high", "medium", "none"]), { + input: 0.6, + cached: 0.12, + output: 2.4, + }), + ...priced(variantIds("gemini-3-7-flash", ["high", "medium", "low"]), { + input: 1.5, + cached: 0.15, + output: 7.5, + }), + ...priced(variantIds("gemini-3-1-pro", ["high", "low"]), { + input: 2, + cached: 0.2, + output: 12, + }), + ...priced(variantIds("deepseek-v4-pro", ["max", "high", "low"]), { + input: 1.32, + cached: 0.04, + output: 3.96, + }), +}; + +// Each transport gets its own provider namespace. They share today's upstream +// rate snapshot, but can diverge independently if Devin changes one channel. +export const DEFAULT_PRICING_DEVIN = { + "devin-cli": { ...DEVIN_MODEL_PRICING }, + dv: { ...DEVIN_MODEL_PRICING }, + "devin-desktop": { ...DEVIN_MODEL_PRICING }, + "devin-cli-agentic": { ...DEVIN_MODEL_PRICING }, + dva: { ...DEVIN_MODEL_PRICING }, +}; diff --git a/src/shared/constants/pricing/frontier-labs.ts b/src/shared/constants/pricing/frontier-labs.ts index d20a187720..b8a80d3bf1 100644 --- a/src/shared/constants/pricing/frontier-labs.ts +++ b/src/shared/constants/pricing/frontier-labs.ts @@ -8,6 +8,7 @@ import { GPT_5_6_LUNA_PRICING, GPT_5_6_SOL_PRICING, GPT_5_6_TERRA_PRICING, + CLAUDE_FABLE_5_1_PRICING, CLAUDE_FABLE_5_PRICING, CLAUDE_OPUS_5_PRICING, CLAUDE_OPUS_4_PRICING, @@ -213,6 +214,7 @@ export const DEFAULT_PRICING_FRONTIER = { // Common model IDs (without dates) used across providers // Intentional duplicates of dot-notation variants (e.g. claude-opus-4.6) // to cover hyphen-notation IDs (claude-opus-4-6) used by some clients + "claude-fable-5-1": CLAUDE_FABLE_5_1_PRICING, "claude-fable-5": CLAUDE_FABLE_5_PRICING, "claude-opus-5": CLAUDE_OPUS_5_PRICING, "claude-sonnet-5": CLAUDE_SONNET_5_PRICING, diff --git a/src/shared/constants/pricing/oauth-subscriptions.ts b/src/shared/constants/pricing/oauth-subscriptions.ts index 87f5aa320f..9d255e0e24 100644 --- a/src/shared/constants/pricing/oauth-subscriptions.ts +++ b/src/shared/constants/pricing/oauth-subscriptions.ts @@ -3,6 +3,7 @@ * Pure data; merged by default-pricing.ts via spread (god-file decomposition; semantic split). */ import { + CLAUDE_FABLE_5_1_PRICING, CLAUDE_OPUS_5_PRICING, GEMINI_3_7_FLASH_PROMO_PRICING, GPT_5_3_CODEX_PRICING, @@ -20,6 +21,7 @@ const ANTIGRAVITY_GEMINI_3_7_PRICING = { export const DEFAULT_PRICING_OAUTH = { cc: { + "claude-fable-5-1": CLAUDE_FABLE_5_1_PRICING, "claude-fable-5": { input: 10.0, output: 50.0, diff --git a/src/shared/constants/pricing/shared-tiers.ts b/src/shared/constants/pricing/shared-tiers.ts index 542e03521a..7cb674df09 100644 --- a/src/shared/constants/pricing/shared-tiers.ts +++ b/src/shared/constants/pricing/shared-tiers.ts @@ -60,6 +60,14 @@ export const CLAUDE_FABLE_5_PRICING = { cache_creation: 15.0, }; +export const CLAUDE_FABLE_5_1_PRICING = { + input: 10.0, + output: 50.0, + cached: 0.25, + reasoning: 50.0, + cache_creation: 12.5, +}; + export const CLAUDE_OPUS_5_PRICING = { input: 5.0, output: 25.0, diff --git a/tests/unit/claude-fable-5-1.test.ts b/tests/unit/claude-fable-5-1.test.ts new file mode 100644 index 0000000000..b494db3d3a --- /dev/null +++ b/tests/unit/claude-fable-5-1.test.ts @@ -0,0 +1,172 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + getModelTargetFormat, + getModelsByProviderId, + supportsClaudeMaxEffort, + supportsXHighEffort, +} from "../../open-sse/config/providerModels.ts"; +import { getUnsupportedParams } from "../../open-sse/config/providerRegistry.ts"; +import { modelHasNativeContext1m } from "../../open-sse/config/claudeCodeCompatibleIdentity.ts"; +import { modelSupportsContext1mBeta } from "../../open-sse/config/context1m.ts"; +import { normalizeClaudeAdaptiveThinking } from "../../open-sse/services/claudeAdaptiveThinking.ts"; +import { getNextFamilyFallback } from "../../open-sse/services/modelFamilyFallback.ts"; +import { getModelPricing } from "../../open-sse/services/providerCostData.ts"; +import { getStaticModelsForProvider } from "../../src/lib/providers/staticModels.ts"; +import { getDefaultPricing } from "../../src/shared/constants/pricing.ts"; +import { + getModelSpec, + normalizeForcedToolChoiceForModel, + normalizeThinkingForModel, +} from "../../src/shared/constants/modelSpecs.ts"; + +const MODEL_ID = "claude-fable-5-1"; +const BEDROCK_MODEL_ID = "anthropic.claude-fable-5-1"; +const EFFORTS = ["low", "medium", "high", "xhigh", "max"]; + +test("Claude Fable 5.1 is registered only on verified launch surfaces", () => { + for (const [providerId, modelId] of [ + ["anthropic", MODEL_ID], + ["claude", MODEL_ID], + ["claude-web", MODEL_ID], + ["bedrock", BEDROCK_MODEL_ID], + ["vertex", MODEL_ID], + ["vertex-partner", MODEL_ID], + ] as const) { + const model = getModelsByProviderId(providerId).find((entry) => entry.id === modelId); + assert.ok(model, `${providerId} must expose ${modelId}`); + if (providerId === "vertex" || providerId === "vertex-partner") { + assert.equal(model.targetFormat, "claude", `${providerId} wire format`); + } else { + assert.equal(model.contextLength, 1_000_000, `${providerId} context window`); + assert.equal(model.maxOutputTokens, 128_000, `${providerId} max output`); + assert.deepEqual(model.supportedThinkingEfforts, EFFORTS, `${providerId} effort levels`); + } + } + + assert.equal(getModelTargetFormat("vertex", MODEL_ID), "claude"); + assert.equal(getModelTargetFormat("vertex-partner", MODEL_ID), "claude"); + + for (const providerId of ["github", "ghe-copilot", "kiro"]) { + const ids = new Set(getModelsByProviderId(providerId).map((entry) => entry.id)); + assert.equal(ids.has(MODEL_ID), false, `${providerId} availability is not verified`); + } + + const cursorModels = new Map( + getModelsByProviderId("cursor").map((entry) => [entry.id, entry] as const) + ); + assert.equal(cursorModels.has(MODEL_ID), false, "cursor exposes only selectable variants"); + + const cursorApiIds = new Set(getModelsByProviderId("cursor-api").map((entry) => entry.id)); + assert.equal(cursorApiIds.has(MODEL_ID), false); + for (const effort of EFFORTS) { + const cursorId = `${MODEL_ID}-thinking-${effort}`; + const cursor1mId = `${cursorId}-1m`; + assert.equal(cursorModels.has(`${MODEL_ID}-${effort}`), false); + assert.equal(cursorModels.get(cursorId)?.contextLength, 300_000, cursorId); + assert.equal(cursorModels.get(cursorId)?.maxOutputTokens, 128_000, cursorId); + assert.equal(cursorApiIds.has(cursorId), true, `cursor-api must expose ${cursorId}`); + assert.equal(cursorModels.get(cursor1mId)?.contextLength, 1_000_000, cursor1mId); + assert.equal(cursorModels.get(cursor1mId)?.maxOutputTokens, 128_000, cursor1mId); + assert.equal(cursorApiIds.has(cursor1mId), true, `cursor-api must expose ${cursor1mId}`); + } + + assert.ok( + getStaticModelsForProvider("claude")?.some((entry) => entry.id === MODEL_ID), + "Claude OAuth static discovery must expose Fable 5.1" + ); + assert.equal( + getNextFamilyFallback(`claude/${MODEL_ID}`, new Set([`claude/${MODEL_ID}`])), + "claude/claude-fable-5" + ); +}); + +test("Claude Fable 5.1 has native 1M context and adaptive-only thinking", () => { + assert.equal(modelHasNativeContext1m(MODEL_ID), true); + assert.equal(modelHasNativeContext1m(BEDROCK_MODEL_ID), true); + assert.equal(modelSupportsContext1mBeta(MODEL_ID), false); + + const spec = getModelSpec(MODEL_ID); + assert.equal(spec?.contextWindow, 1_000_000); + assert.equal(spec?.maxOutputTokens, 128_000); + assert.equal(spec?.supportsThinking, true); + assert.equal(spec?.supportsTools, true); + assert.equal(spec?.supportsVision, true); + assert.equal(spec?.adaptiveThinkingOnly, true); + assert.equal(spec?.rejectsThinkingDisabled, true); + assert.equal( + (spec as typeof spec & { rejectsForcedToolChoice?: boolean })?.rejectsForcedToolChoice, + true + ); + + assert.equal(getModelSpec(`global.${BEDROCK_MODEL_ID}`), spec); + assert.equal(supportsXHighEffort("claude", MODEL_ID), true); + assert.equal(supportsClaudeMaxEffort(MODEL_ID), true); +}); + +test("Claude Fable 5.1 strips unsupported sampling parameters", () => { + for (const providerId of ["anthropic", "claude"] as const) { + const unsupported = getUnsupportedParams(providerId, MODEL_ID); + for (const param of ["temperature", "top_p", "top_k"]) { + assert.ok(unsupported.includes(param), `${providerId}/${MODEL_ID} must strip ${param}`); + } + } +}); + +test("Claude Fable 5.1 normalizes disabled and manual thinking to adaptive", () => { + const withoutDisabled = normalizeThinkingForModel( + { model: MODEL_ID, thinking: { type: "disabled" }, marker: true }, + MODEL_ID + ); + assert.equal("thinking" in withoutDisabled, false); + assert.equal(withoutDisabled.marker, true); + + const adaptive = normalizeClaudeAdaptiveThinking( + { model: MODEL_ID, thinking: { type: "enabled", budget_tokens: 64_000 } }, + MODEL_ID + ); + assert.deepEqual(adaptive.thinking, { type: "adaptive" }); +}); + +test("Claude Fable 5.1 relaxes forced tool choices without removing tools", () => { + for (const toolChoice of [ + "required", + "any", + { type: "any" }, + { type: "tool", name: "read_file" }, + { type: "function", function: { name: "read_file" } }, + ]) { + const tools = [{ name: "read_file", input_schema: { type: "object" } }]; + const result = normalizeForcedToolChoiceForModel( + { model: MODEL_ID, tools, tool_choice: toolChoice, marker: true }, + MODEL_ID + ); + assert.equal("tool_choice" in result, false); + assert.equal(result.tools, tools); + assert.equal(result.marker, true); + } + + const auto = { model: MODEL_ID, tool_choice: { type: "auto" } }; + assert.equal(normalizeForcedToolChoiceForModel(auto, MODEL_ID), auto); + + const older = { model: "claude-fable-5", tool_choice: { type: "tool", name: "read_file" } }; + assert.equal(normalizeForcedToolChoiceForModel(older, "claude-fable-5"), older); +}); + +test("Claude Fable 5.1 pricing matches Anthropic's published rates", () => { + for (const providerId of ["anthropic", "cc"] as const) { + const price = getDefaultPricing()[providerId][MODEL_ID]; + assert.equal(price.input, 10); + assert.equal(price.output, 50); + assert.equal(price.cached, 0.25); + assert.equal(price.reasoning, 50); + assert.equal(price.cache_creation, 12.5); + } + + assert.deepEqual(getModelPricing("anthropic", MODEL_ID), { + inputCostPer1M: 10, + outputCostPer1M: 50, + isFree: false, + }); +}); diff --git a/tests/unit/claude-web-sonnet5-registry-6209.test.ts b/tests/unit/claude-web-sonnet5-registry-6209.test.ts index 9930a04ae2..a2f568fc9c 100644 --- a/tests/unit/claude-web-sonnet5-registry-6209.test.ts +++ b/tests/unit/claude-web-sonnet5-registry-6209.test.ts @@ -9,6 +9,7 @@ test("claude-web registry matches the current selectable model set", () => { assert.deepEqual( ids, [ + "claude-fable-5-1", "claude-fable-5", "claude-haiku-4-5-20251001", "claude-opus-5", diff --git a/tests/unit/cursor-auto-catalog-entry.test.ts b/tests/unit/cursor-auto-catalog-entry.test.ts index a194885927..aea92b7394 100644 --- a/tests/unit/cursor-auto-catalog-entry.test.ts +++ b/tests/unit/cursor-auto-catalog-entry.test.ts @@ -28,4 +28,31 @@ describe("ensureCursorAutoCatalogEntry", () => { assert.equal(models.filter((m) => m.id === "auto").length, 1); assert.equal(models.filter((m) => m.id === "auto-cost").length, 1); }); + + it("injects supported 1M context variants immediately before their base ids", () => { + const models = ensureCursorAutoCatalogEntry([ + { id: "claude-opus-5-thinking-max-fast", name: "Claude Opus 5 Max Thinking Fast" }, + { id: "gpt-5.6-sol-max", name: "GPT-5.6 Sol Max" }, + { id: "gpt-5.6-sol-max-fast", name: "GPT-5.6 Sol Max Fast" }, + ]); + const ids = models.map((model) => model.id); + for (const baseId of ["claude-opus-5-thinking-max-fast", "gpt-5.6-sol-max"]) { + const oneMillionPosition = ids.indexOf(`${baseId}-1m`); + assert.ok(oneMillionPosition >= 0); + assert.equal(ids[oneMillionPosition + 1], baseId); + assert.equal( + (models[oneMillionPosition] as { contextLength?: number }).contextLength, + 1_000_000 + ); + } + assert.equal(ids.includes("gpt-5.6-sol-max-fast-1m"), false); + }); + + it("does not duplicate a discovered 1M context variant", () => { + const models = ensureCursorAutoCatalogEntry([ + { id: "gpt-5.6-luna-max-1m", name: "GPT-5.6 Luna 1M Max" }, + { id: "gpt-5.6-luna-max", name: "GPT-5.6 Luna Max" }, + ]); + assert.equal(models.filter((model) => model.id === "gpt-5.6-luna-max-1m").length, 1); + }); }); diff --git a/tests/unit/cursor-available-models.test.ts b/tests/unit/cursor-available-models.test.ts index bf4335b388..28e822427d 100644 --- a/tests/unit/cursor-available-models.test.ts +++ b/tests/unit/cursor-available-models.test.ts @@ -17,7 +17,9 @@ describe("normalizeCursorAvailableModelsPayload", () => { }); assert.equal(models[0].id, "auto"); assert.ok(models.some((m) => m.id === "claude-opus-5-high")); + assert.ok(models.some((m) => m.id === "claude-opus-5-high-1m")); assert.ok(models.some((m) => m.id === "gpt-5.6-sol-high")); + assert.ok(models.some((m) => m.id === "gpt-5.6-sol-high-1m")); assert.ok(models.some((m) => m.id === "auto-cost")); assert.equal(models.find((m) => m.id === "claude-opus-5-high")?.name, "Opus 5"); assert.equal(models.find((m) => m.id === "claude-opus-5-high")?.owned_by, "cursor"); diff --git a/tests/unit/cursor-catalog-combo-compat.test.ts b/tests/unit/cursor-catalog-combo-compat.test.ts index cd3a8f481a..d27613b317 100644 --- a/tests/unit/cursor-catalog-combo-compat.test.ts +++ b/tests/unit/cursor-catalog-combo-compat.test.ts @@ -33,11 +33,11 @@ const LEGACY_GROK_ALIASES = { "grok-4.5-fast-xhigh": "cursor-grok-4.5-xhigh-fast", } as const; -test("keeps legacy Cursor combo model ids in the static catalog", () => { +test("keeps legacy Cursor combo model ids out of the curated static catalog", () => { const catalogIds = new Set(cursorProvider.models.map((model) => model.id)); for (const modelId of LEGACY_CURSOR_COMBO_MODEL_IDS) { - assert.ok(catalogIds.has(modelId), `missing Cursor catalog model: ${modelId}`); + assert.equal(catalogIds.has(modelId), false, `unexpected Cursor catalog model: ${modelId}`); } }); diff --git a/tests/unit/cursor-model-effort-suffix-7289.test.ts b/tests/unit/cursor-model-effort-suffix-7289.test.ts index 10e4023e14..aeb2f9cdb1 100644 --- a/tests/unit/cursor-model-effort-suffix-7289.test.ts +++ b/tests/unit/cursor-model-effort-suffix-7289.test.ts @@ -80,3 +80,35 @@ test("resolveRequestedModel splits cursor-grok effort + fast together", () => { ], }); }); + +test("resolveRequestedModel expands Claude 1M catalog ids into complete wire parameters", () => { + assert.deepEqual(resolveRequestedModel("claude-opus-5-thinking-max-fast-1m"), { + modelId: "claude-opus-5", + parameters: [ + { id: "thinking", value: "true" }, + { id: "context", value: "1m" }, + { id: "effort", value: "max" }, + { id: "fast", value: "true" }, + ], + }); + assert.deepEqual(resolveRequestedModel("claude-4.6-sonnet-high-thinking-1m"), { + modelId: "claude-sonnet-4-6", + parameters: [ + { id: "thinking", value: "true" }, + { id: "context", value: "1m" }, + { id: "effort", value: "high" }, + ], + }); +}); + +test("resolveRequestedModel expands GPT-5.6 1M ids and keeps fast disabled", () => { + const id = "gpt-5.6-sol-xhigh-1m"; + assert.deepEqual(resolveRequestedModel(id, { liveCatalogIds: new Set([id]) }), { + modelId: "gpt-5.6-sol", + parameters: [ + { id: "context", value: "1m" }, + { id: "reasoning", value: "xhigh" }, + { id: "fast", value: "false" }, + ], + }); +}); diff --git a/tests/unit/cursor-registry-claude-families.test.ts b/tests/unit/cursor-registry-claude-families.test.ts index 46a41970a0..0236f99682 100644 --- a/tests/unit/cursor-registry-claude-families.test.ts +++ b/tests/unit/cursor-registry-claude-families.test.ts @@ -1,51 +1,196 @@ -import test from "node:test"; import assert from "node:assert/strict"; +import test from "node:test"; + import { cursorProvider } from "../../open-sse/config/providers/registry/cursor/index.ts"; -const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; +const CURSOR_FAMILY_REPRESENTATIVES = [ + "cursor-grok-4.6-high-fast", + "composer-2.5", + "claude-fable-5-1-thinking-high", + "claude-opus-5-thinking-high", + "claude-opus-4-8-thinking-high", + "claude-sonnet-5-thinking-high", + "claude-4.6-sonnet-medium-thinking", + "claude-4.5-haiku-thinking", + "gpt-5.6-sol-medium", + "gpt-5.6-terra-medium", + "gpt-5.6-luna-medium", + "gemini-3.7-flash-high", + "gemini-3.1-pro", + "kimi-k3-max", + "kimi-k2.7-code", + "glm-5.2-high", +] as const; -function modelIds(): Set { - return new Set(cursorProvider.models.map((m) => m.id)); -} - -test("cursor registry excludes retired Gemini 3.5 Flash", () => { - assert.equal(modelIds().has("gemini-3.5-flash"), false); +test("cursor registry keeps every selected model family", () => { + const allIds = cursorProvider.models.map((model) => model.id); + const ids = new Set(allIds); + assert.equal(ids.size, allIds.length, "Cursor catalog model ids must be unique"); + for (const id of CURSOR_FAMILY_REPRESENTATIVES) { + assert.ok(ids.has(id), `missing Cursor model: ${id}`); + } }); -test("cursor registry includes Claude Opus 4.8 effort + thinking + fast variants", () => { - const ids = modelIds(); - for (const effort of EFFORTS) { - assert.ok(ids.has(`claude-opus-4-8-${effort}`), `missing claude-opus-4-8-${effort}`); - assert.ok(ids.has(`claude-opus-4-8-${effort}-fast`), `missing claude-opus-4-8-${effort}-fast`); +test("cursor registry omits redundant bare ids for parameterized models", () => { + const ids = new Set(cursorProvider.models.map((model) => model.id)); + for (const id of [ + "grok-4.6", + "claude-fable-5-1", + "claude-opus-5", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-haiku-4-5", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gemini-3.7-flash", + "kimi-k3", + "glm-5.2", + ]) { + assert.equal(ids.has(id), false, `unexpected bare Cursor model: ${id}`); + } +}); + +test("cursor registry keeps thinking, effort/reasoning and fast variants selectable", () => { + const ids = new Set(cursorProvider.models.map((model) => model.id)); + for (const id of [ + "cursor-grok-4.6-xhigh-fast", + "composer-2.5-fast", + "claude-fable-5-1-thinking-max", + "claude-opus-5-thinking-xhigh-fast", + "claude-opus-4-8-thinking-max-fast", + "claude-sonnet-5-thinking-max", + "claude-4.6-sonnet-max-thinking", + "claude-4.5-haiku-thinking", + "gpt-5.6-sol-max-fast", + "gpt-5.6-terra-max-fast", + "gpt-5.6-luna-max-fast", + "gemini-3.7-flash-high", + "kimi-k3-max", + "glm-5.2-max", + ]) { + assert.ok(ids.has(id), `missing selectable Cursor variant: ${id}`); + } +}); + +test("cursor registry exposes every supported 1M context variant", () => { + const ids = cursorProvider.models.map((model) => model.id); + const oneMillionVariants = cursorProvider.models.filter((model) => model.id.endsWith("-1m")); + assert.equal(oneMillionVariants.length, 77); + for (const variant of oneMillionVariants) { + assert.match(variant.name, /\b1M\b/); + assert.equal(variant.contextLength, 1_000_000); + const position = ids.indexOf(variant.id); + assert.equal(ids[position + 1], variant.id.slice(0, -"-1m".length)); + } + for (const id of [ + "claude-fable-5-1-thinking-max-1m", + "claude-opus-5-thinking-max-fast-1m", + "claude-opus-4-8-thinking-max-fast-1m", + "claude-sonnet-5-thinking-max-1m", + "claude-4.6-sonnet-max-thinking-1m", + "gpt-5.6-sol-max-1m", + "gpt-5.6-terra-max-1m", + "gpt-5.6-luna-max-1m", + ]) { assert.ok( - ids.has(`claude-opus-4-8-thinking-${effort}`), - `missing claude-opus-4-8-thinking-${effort}` + oneMillionVariants.some((model) => model.id === id), + `missing 1M variant: ${id}` ); + } + assert.equal( + oneMillionVariants.some( + (model) => model.id.startsWith("gpt-5.6-") && model.id.includes("-fast") + ), + false, + "Cursor does not offer fast processing with GPT-5.6 1M context" + ); +}); + +test("cursor registry records the default context for context-selectable families", () => { + const models = new Map(cursorProvider.models.map((model) => [model.id, model])); + assert.equal(models.get("claude-fable-5-1-thinking-max")?.contextLength, 300_000); + assert.equal(models.get("claude-opus-5-thinking-max")?.contextLength, 300_000); + assert.equal(models.get("claude-opus-4-8-thinking-max")?.contextLength, 300_000); + assert.equal(models.get("claude-sonnet-5-thinking-max")?.contextLength, 300_000); + assert.equal(models.get("claude-4.6-sonnet-max-thinking")?.contextLength, 200_000); + assert.equal(models.get("gpt-5.6-sol-max")?.contextLength, 272_000); + assert.equal(models.get("gpt-5.6-terra-max")?.contextLength, 272_000); + assert.equal(models.get("gpt-5.6-luna-max")?.contextLength, 272_000); +}); + +test("cursor registry orders each model family by quality, thinking and speed", () => { + const ids = cursorProvider.models.map((model) => model.id); + for (const orderedIds of [ + ["cursor-grok-4.6-xhigh-fast", "cursor-grok-4.6-xhigh", "cursor-grok-4.6-low"], + ["composer-2.5-fast", "composer-2.5"], + ["claude-fable-5-1-thinking-max", "claude-fable-5-1-thinking-low"], + ["claude-opus-5-thinking-high-fast", "claude-opus-5-high-fast", "claude-opus-5-low"], + ["claude-opus-4-8-thinking-max-fast", "claude-opus-4-8-max-fast", "claude-opus-4-8-low"], + ["claude-sonnet-5-thinking-max", "claude-sonnet-5-max", "claude-sonnet-5-low"], + ["claude-4.6-sonnet-max-thinking", "claude-4.6-sonnet-max", "claude-4.6-sonnet-low"], + ["claude-4.5-haiku-thinking", "claude-4.5-haiku"], + ["gpt-5.6-sol-max-fast", "gpt-5.6-sol-max", "gpt-5.6-sol-none"], + ["gpt-5.6-terra-max-fast", "gpt-5.6-terra-max", "gpt-5.6-terra-none"], + ["gpt-5.6-luna-max-fast", "gpt-5.6-luna-max", "gpt-5.6-luna-none"], + ["gemini-3.7-flash-high", "gemini-3.7-flash-low"], + ["kimi-k3-max", "kimi-k3-low"], + ["glm-5.2-max", "glm-5.2-high"], + ]) { + const positions = orderedIds.map((id) => ids.indexOf(id)); assert.ok( - ids.has(`claude-opus-4-8-thinking-${effort}-fast`), - `missing claude-opus-4-8-thinking-${effort}-fast` + positions.every((position) => position >= 0), + `missing ordered ids: ${orderedIds}` + ); + assert.deepEqual( + positions, + [...positions].sort((left, right) => left - right) ); } }); -test("cursor registry includes Claude Fable 5 effort + thinking variants", () => { - const ids = modelIds(); - for (const effort of EFFORTS) { - assert.ok(ids.has(`claude-fable-5-${effort}`), `missing claude-fable-5-${effort}`); - assert.ok( - ids.has(`claude-fable-5-thinking-${effort}`), - `missing claude-fable-5-thinking-${effort}` +test("cursor registry uses compact Xhigh labels", () => { + const xhighVariants = cursorProvider.models.filter((model) => model.id.includes("xhigh")); + assert.ok(xhighVariants.length > 0); + for (const variant of xhighVariants) { + assert.match(variant.name, /\bXhigh\b/); + assert.doesNotMatch(variant.name, /Extra High/); + } +}); + +test("cursor registry excludes unrelated model families", () => { + const ids = cursorProvider.models.map((model) => model.id); + for (const fragment of [ + "grok-4.5", + "gpt-5.5", + "gpt-5.4", + "gpt-5.3", + "gpt-5.2", + "claude-fable-5-thinking", + "claude-opus-4-7", + "gemini-3.6-flash", + "gemini-3.5-flash", + "gemini-3-flash", + ]) { + assert.equal( + ids.some((id) => id.includes(fragment)), + false, + `unexpected Cursor model family: ${fragment}` ); } }); -test("cursor registry includes Claude Sonnet 5 effort + thinking variants", () => { - const ids = modelIds(); - for (const effort of EFFORTS) { - assert.ok(ids.has(`claude-sonnet-5-${effort}`), `missing claude-sonnet-5-${effort}`); - assert.ok( - ids.has(`claude-sonnet-5-thinking-${effort}`), - `missing claude-sonnet-5-thinking-${effort}` - ); +test("cursor registry keeps Fable 5.1 capability metadata on every selectable variant", () => { + const variants = cursorProvider.models.filter((model) => + model.id.startsWith("claude-fable-5-1-thinking-") + ); + assert.equal(variants.length, 10); + assert.deepEqual( + new Set(variants.map((variant) => variant.contextLength)), + new Set([300_000, 1_000_000]) + ); + for (const variant of variants) { + assert.equal(variant.maxOutputTokens, 128_000); } }); diff --git a/tests/unit/devin-cli-catalog.test.ts b/tests/unit/devin-cli-catalog.test.ts index 7cd92e15b1..330ee8ac31 100644 --- a/tests/unit/devin-cli-catalog.test.ts +++ b/tests/unit/devin-cli-catalog.test.ts @@ -2,54 +2,124 @@ import assert from "node:assert/strict"; import test from "node:test"; import { devin_cliProvider } from "../../open-sse/config/providers/registry/devin-cli/index.ts"; +import { devin_cli_agenticProvider } from "../../open-sse/config/providers/registry/devin-cli-agentic/index.ts"; import { devin_desktopProvider } from "../../open-sse/config/providers/registry/devin-desktop/index.ts"; import { DEVIN_MODEL_CATALOG } from "../../open-sse/config/providers/registry/devin/catalog.ts"; +import { DEVIN_MODEL_PRICING } from "../../src/shared/constants/pricing/devin.ts"; +import { DEFAULT_PRICING, getPricingForModel } from "../../src/shared/constants/pricing.ts"; -test("Devin CLI and Desktop use the shared catalog without duplicate model ids", () => { - const ids = DEVIN_MODEL_CATALOG.map((model) => model.id); +const catalogIds = DEVIN_MODEL_CATALOG.map((model) => model.id); +test("Devin transports expose the same curated catalog without duplicate ids", () => { assert.equal(devin_cliProvider.models, DEVIN_MODEL_CATALOG); assert.equal(devin_desktopProvider.models, DEVIN_MODEL_CATALOG); - assert.equal(new Set(ids).size, ids.length); - assert.ok(ids.every((id) => !id.toLowerCase().includes("byok"))); + assert.deepEqual( + devin_cli_agenticProvider.models.map((model) => model.id), + catalogIds + ); + assert.equal(catalogIds.length, 110); + assert.equal(new Set(catalogIds).size, catalogIds.length); + assert.ok(catalogIds.every((id) => !id.toLowerCase().includes("byok"))); }); -test("Devin CLI catalog includes the refreshed native model ids", () => { - const ids = new Set(DEVIN_MODEL_CATALOG.map((model) => model.id)); +test("Devin catalog contains only the operator-selected model families", () => { + const required = [ + "claude-fable-5-1-max", + "claude-opus-5-max-fast", + "claude-opus-4-8-max-fast", + "claude-sonnet-5-max", + "claude-sonnet-4-6-thinking-1m", + "MODEL_PRIVATE_11", + "gpt-5-6-sol-max-priority", + "gpt-5-6-terra-max-priority", + "gpt-5-6-luna-max-priority", + "kimi-k3-max", + "kimi-k2-7", + "glm-5-3-max", + "glm-5-3-flash-max", + "swe-1-7", + "swe-1-7-lightning", + "adaptive", + "grok-4-6-xhigh", + "inkling-max", + "deepseek-v4-flash-max", + "nemotron-3-ultra-high", + "gemini-3-7-flash-high", + "gemini-3-1-pro-high", + "deepseek-v4-pro-max", + ]; + + for (const id of required) { + assert.ok(catalogIds.includes(id), `expected selected Devin model id: ${id}`); + } for (const id of [ - "swe-1-7-lightning", "claude-5-fable-max", - "gpt-5-6-sol-max", + "claude-opus-4-7-max", "gpt-5-5-high", - "glm-5-2-max-1m", - "claude-opus-5-low", - "claude-opus-5-medium", - "claude-opus-5-high", - "claude-opus-5-xhigh", - "claude-opus-5-max", - "gemini-3-7-flash-minimal", - "gemini-3-7-flash-low", - "gemini-3-7-flash-medium", - "gemini-3-7-flash-high", - "kimi-k3-low", - "kimi-k3-high", - "kimi-k3-max", - "inkling-none", - "inkling-low", - "inkling-medium", - "inkling-high", - "inkling-xhigh", - "inkling-max", + "gemini-3-6-flash-high", + "grok-4-5-high", + "deepseek-v4", + "nemotron-3-ultra-nvfp4", + "swe-1-6-fast", ]) { - assert.ok(ids.has(id), `expected refreshed Devin model id: ${id}`); + assert.equal(catalogIds.includes(id), false, `unselected Devin model must stay absent: ${id}`); } }); -test("Devin CLI catalog does not expose retired dotted or review model ids", () => { - const ids = new Set(DEVIN_MODEL_CATALOG.map((model) => model.id)); +test("Devin catalog keeps higher-quality choices first", () => { + assert.deepEqual(catalogIds.slice(0, 5), [ + "claude-fable-5-1-max", + "claude-fable-5-1-xhigh", + "claude-fable-5-1-high", + "claude-fable-5-1-medium", + "claude-fable-5-1-low", + ]); + assert.deepEqual(catalogIds.slice(5, 9), [ + "claude-opus-5-max-fast", + "claude-opus-5-max", + "claude-opus-5-xhigh-fast", + "claude-opus-5-xhigh", + ]); +}); - for (const id of ["swe-1.6-fast", "swe-1.6", "claude-opus-4.7-review"]) { - assert.equal(ids.has(id), false, `retired Devin model id must stay absent: ${id}`); +test("every curated Devin model has an exact live provider price", () => { + assert.deepEqual(new Set(Object.keys(DEVIN_MODEL_PRICING)), new Set(catalogIds)); + + for (const provider of ["devin-cli", "dv", "devin-desktop", "devin-cli-agentic", "dva"]) { + for (const id of catalogIds) { + assert.ok(getPricingForModel(provider, id), `missing ${provider}/${id} pricing`); + } } }); + +test("Devin pricing remains provider-bound and preserves fast-tier rates", () => { + assert.notEqual(DEFAULT_PRICING["devin-cli"], DEFAULT_PRICING.anthropic); + assert.deepEqual(getPricingForModel("devin-cli", "claude-sonnet-5-max"), { + input: 2, + cached: 0.2, + output: 10, + }); + assert.deepEqual(getPricingForModel("anthropic", "claude-sonnet-5"), { + input: 3, + output: 15, + cached: 1.5, + reasoning: 22.5, + cache_creation: 3, + }); + assert.deepEqual(getPricingForModel("devin-cli", "gpt-5-6-sol-max-priority"), { + input: 8, + cached: 0.8, + output: 40, + }); +}); + +test("Devin catalog carries the live output limits for representative models", () => { + const models = new Map(DEVIN_MODEL_CATALOG.map((entry) => [entry.id, entry])); + + assert.equal(models.get("claude-fable-5-1-max")?.maxOutputTokens, 128_000); + assert.equal(models.get("MODEL_PRIVATE_11")?.maxOutputTokens, 64_000); + assert.equal(models.get("kimi-k2-7")?.maxOutputTokens, 16_000); + assert.equal(models.get("grok-4-6-xhigh")?.maxOutputTokens, 100_000); + assert.equal(models.get("gemini-3-7-flash-high")?.maxOutputTokens, 65_535); +}); diff --git a/tests/unit/fixtures/cursor-rewrite-failure-ids.ts b/tests/unit/fixtures/cursor-rewrite-failure-ids.ts index dd064537c6..641d383cb8 100644 --- a/tests/unit/fixtures/cursor-rewrite-failure-ids.ts +++ b/tests/unit/fixtures/cursor-rewrite-failure-ids.ts @@ -4,11 +4,16 @@ * Smoke checklist for catalog-aware pass-through. */ export const CURSOR_REWRITE_FAILURE_IDS = [ - // Claude (52) + // Claude (57) "claude-4.5-opus-high", "claude-4.6-opus-high", "claude-4.6-opus-max", "claude-4.6-sonnet-medium", + "claude-fable-5-1-thinking-low", + "claude-fable-5-1-thinking-medium", + "claude-fable-5-1-thinking-high", + "claude-fable-5-1-thinking-xhigh", + "claude-fable-5-1-thinking-max", "claude-fable-5-low", "claude-fable-5-medium", "claude-fable-5-high", diff --git a/tests/unit/guardrails/visionBridgeRouter.test.ts b/tests/unit/guardrails/visionBridgeRouter.test.ts index 3197f08e20..dc685a61ff 100644 --- a/tests/unit/guardrails/visionBridgeRouter.test.ts +++ b/tests/unit/guardrails/visionBridgeRouter.test.ts @@ -263,12 +263,12 @@ test("getFallbackModels — excludes fallbacks missing from an authoritative liv test("getFallbackModels — keeps registered effort variants backed by a live base model", async () => { const fallbacks = await getFallbackModels( - "cu/gpt-5.3-codex", + "cu/claude-fable-5-1-thinking-max", { maxFallbackAttempts: 6 }, - authoritativeCatalogDeps("cu", () => ["gpt-5.3-codex"]) + authoritativeCatalogDeps("cu", () => ["claude-fable-5-1"]) ); - assert.ok(fallbacks.includes("cu/gpt-5.3-codex-low")); + assert.ok(fallbacks.includes("cu/claude-fable-5-1-thinking-high")); }); // ── recordLatency / getLatencyStats ───────────────────────────────────────── diff --git a/tests/unit/live-model-catalog-reconciliation-8926.test.ts b/tests/unit/live-model-catalog-reconciliation-8926.test.ts index 4bd415dee3..5bc1d15c54 100644 --- a/tests/unit/live-model-catalog-reconciliation-8926.test.ts +++ b/tests/unit/live-model-catalog-reconciliation-8926.test.ts @@ -117,24 +117,24 @@ test("#8926: explicit custom model overrides live-catalog exclusion", async () = }); test("#8926: effort helper identifies only explicitly registered variants", () => { - assert.equal(isRegisteredProviderEffortVariant("cursor", "gpt-5.3-codex-high"), true); + assert.equal(isRegisteredProviderEffortVariant("cursor", "claude-fable-5-1-thinking-high"), true); assert.equal( - isRegisteredProviderEffortVariant("cursor", "gpt-5.3-codex-max"), + isRegisteredProviderEffortVariant("cursor", "claude-fable-5-1-thinking-ultra"), false, "an invented suffix must not bypass live-catalog authority" ); }); test("#8926: registered effort route survives while invented effort route is rejected", async () => { - await seedProviderCatalog("cursor", "cursor-live-8926", ["gpt-5.3-codex"]); + await seedProviderCatalog("cursor", "cursor-live-8926", ["claude-fable-5-1"]); - const registered = await getModelInfo("cursor/gpt-5.3-codex-high"); + const registered = await getModelInfo("cursor/claude-fable-5-1-thinking-high"); assert.equal(registered.provider, "cursor"); - assert.equal(registered.model, "gpt-5.3-codex-high"); + assert.equal(registered.model, "claude-fable-5-1-thinking-high"); - const invented = await getModelInfo("cursor/gpt-5.3-codex-max"); + const invented = await getModelInfo("cursor/claude-fable-5-1-thinking-ultra"); assert.equal(invented.provider, null); assert.equal(invented.errorType, "model_not_found"); @@ -170,13 +170,13 @@ test("#8926: providers without an authoritative live catalog retain static fallb test("#8926: registered effort variant is rejected when its live base is absent", async () => { await seedProviderCatalog("cursor", "cursor-live-without-base-8926", ["cursor-live-only-8926"]); - const explicit = await getModelInfo("cursor/gpt-5.3-codex-high"); + const explicit = await getModelInfo("cursor/claude-fable-5-1-thinking-high"); assert.equal(explicit.provider, null); assert.equal(explicit.errorType, "model_not_found"); assert.match(explicit.errorMessage, /active live catalog/i); - const bare = await getModelInfo("gpt-5.3-codex-high"); + const bare = await getModelInfo("claude-fable-5-1-thinking-high"); assert.equal(bare.provider, null); assert.equal(bare.errorType, "model_not_found"); diff --git a/tests/unit/pricing-constants-split.test.ts b/tests/unit/pricing-constants-split.test.ts index 28acb759c4..90970e1730 100644 --- a/tests/unit/pricing-constants-split.test.ts +++ b/tests/unit/pricing-constants-split.test.ts @@ -1,5 +1,5 @@ // Characterization of the pricing.ts split (god-file decomposition): the host became a barrel that -// re-exports DEFAULT_PRICING (now merged from 4 semantic family files that import shared tier consts) +// re-exports DEFAULT_PRICING (merged from semantic family files that import shared tier consts) // and keeps the helper functions. Pure-data move → behavior identical. Locks: public surface, the // spread-merge integrity, and that lookups/cost math resolve unchanged. import { test } from "node:test"; @@ -14,13 +14,14 @@ test("barrel still exports DEFAULT_PRICING + supported helpers", () => { assert.equal(Object.hasOwn(P, "calculateCostFromTokens"), false); }); -test("DEFAULT_PRICING merges the 4 family files; families partition all entries", async () => { +test("DEFAULT_PRICING merges every family file; families partition all entries", async () => { const merged = Object.keys((P as Record).DEFAULT_PRICING).length; const families: [string, string][] = [ ["oauth-subscriptions", "DEFAULT_PRICING_OAUTH"], ["frontier-labs", "DEFAULT_PRICING_FRONTIER"], ["inference-hosts", "DEFAULT_PRICING_INFERENCE"], ["regional", "DEFAULT_PRICING_REGIONAL"], + ["devin", "DEFAULT_PRICING_DEVIN"], ]; let famTotal = 0; const seen = new Set(); diff --git a/tests/unit/provider-cost-data.test.ts b/tests/unit/provider-cost-data.test.ts new file mode 100644 index 0000000000..157769dfbf --- /dev/null +++ b/tests/unit/provider-cost-data.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getModelPricing, KNOWN_MODEL_PRICING } from "../../open-sse/services/providerCostData.ts"; + +test("provider-specific pricing wins over a generic model fallback", () => { + const genericKey = "provider-price-test-model"; + const providerKey = `devin-cli/${genericKey}`; + const previousGeneric = KNOWN_MODEL_PRICING[genericKey]; + const previousProvider = KNOWN_MODEL_PRICING[providerKey]; + + KNOWN_MODEL_PRICING[genericKey] = { + inputCostPer1M: 9, + outputCostPer1M: 90, + isFree: false, + }; + KNOWN_MODEL_PRICING[providerKey] = { + inputCostPer1M: 1, + outputCostPer1M: 10, + isFree: false, + }; + + try { + assert.deepEqual(getModelPricing("devin-cli", genericKey), { + inputCostPer1M: 1, + outputCostPer1M: 10, + isFree: false, + }); + } finally { + if (previousGeneric) KNOWN_MODEL_PRICING[genericKey] = previousGeneric; + else delete KNOWN_MODEL_PRICING[genericKey]; + if (previousProvider) KNOWN_MODEL_PRICING[providerKey] = previousProvider; + else delete KNOWN_MODEL_PRICING[providerKey]; + } +}); + +test("tier pricing reads the exact Devin provider/model rate", () => { + assert.deepEqual(getModelPricing("devin-cli", "gpt-5-6-luna-max"), { + inputCostPer1M: 0.2, + outputCostPer1M: 1.2, + isFree: false, + }); + assert.deepEqual(getModelPricing("devin-cli", "gpt-5-6-luna-max-priority"), { + inputCostPer1M: 0.4, + outputCostPer1M: 2.4, + isFree: false, + }); +}); diff --git a/tests/unit/provider-models-config.test.ts b/tests/unit/provider-models-config.test.ts index 793605cc4f..51a5f62d18 100644 --- a/tests/unit/provider-models-config.test.ts +++ b/tests/unit/provider-models-config.test.ts @@ -141,16 +141,22 @@ test("GitHub Copilot registry reflects the current supported model lineup", () = assert.equal(ids.includes("gemini-3-flash-preview"), false); }); -test("Claude flagship catalogs keep Fable 5 first", () => { - for (const provider of ["anthropic", "cc", "cw", "gh", "ghe-copilot"]) { +test("verified Anthropic launch catalogs keep Fable 5.1 first", () => { + for (const provider of ["anthropic", "cc", "cw"]) { assert.equal( getProviderModels(provider)[0]?.id, - "claude-fable-5", + "claude-fable-5-1", `${provider} must list the strongest Claude model first` ); } }); +test("Copilot catalogs retain Fable 5 until their Fable 5.1 IDs are verified", () => { + for (const provider of ["gh", "ghe-copilot"]) { + assert.equal(getProviderModels(provider)[0]?.id, "claude-fable-5"); + } +}); + test("Kiro registry exposes the current CLI model lineup with context windows", () => { const kiroModels = getProviderModels("kr"); const byId = new Map(kiroModels.map((model) => [model.id, model])); From cb38bfa6ee105439f1c160aadfe54de7789ad4ea Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 3 Sep 2026 08:10:49 -0300 Subject: [PATCH 13/47] feat(video): redact raw client-snapshot transcript fields in the detailed log (#12150 P2) (#12528) * feat(video): redact raw client-snapshot transcript fields in the detailed log (#12150 P2) clientRawRequest.body is captured before the guardrail chain runs and persisted verbatim by reqLogger.logClientRawRequest, so it retained the client's raw transcript/audioTranscript cue text on video parts even after P1's description redaction. Add redactVideoTranscriptFieldsForLog (new, dependency-light module) and wire it at the logClientRawRequest call site, gated on videoBridgeObserved: redacts the structured transcript fields in the LOGGED copy only, never the body sent to the provider or returned to the client. * refactor(video): extract the guarded client-snapshot log call to keep chatCore within its size budget Fast Quality Gates check:file-size flagged chatCore.ts growing past its frozen ceiling (5985 > 5976) from the P2a wiring. Move the guarded logClientRawRequest call into logClientRawRequestRedacted (new export in videoBridgeSnapshotRedaction.ts, which already owns the redaction), collapsing the inline if-block at the chatCore.ts call site to a single call. Net -4 lines vs the pre-P2a base. Behavior unchanged: non-observed still logs the exact same clientRawRequest.body reference; observed still logs the redacted clone. --- open-sse/handlers/chatCore.ts | 12 +- .../videoBridgeSnapshotRedaction.ts | 135 ++++++++++++ .../videoBridgeSnapshotRedaction.test.ts | 207 ++++++++++++++++++ tests/unit/video-bridge-log-redaction.test.ts | 78 +++++++ 4 files changed, 424 insertions(+), 8 deletions(-) create mode 100644 src/lib/guardrails/videoBridgeSnapshotRedaction.ts create mode 100644 tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 8dbac0018b..d28711ce12 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -360,6 +360,7 @@ import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity"; import { getCacheControlSettings } from "@/lib/cacheControlSettings"; import { guardrailRegistry } from "@/lib/guardrails"; import type { VideoBridgeLogRedactionEntry } from "@/lib/guardrails/videoBridge"; +import { logClientRawRequestRedacted } from "@/lib/guardrails/videoBridgeSnapshotRedaction"; import { shouldPreserveCacheControl, resolveConnectionCacheOverride, @@ -1211,14 +1212,9 @@ export async function handleChatCore({ }); const pendingScope = { id: pendingRequestId, model, provider, connectionId: pendingConnId }; const providerRequestCapture = createPreparedRequestLogger(reqLogger, pendingScope); - // 0. Log client raw request (before format conversion) - if (clientRawRequest) { - reqLogger.logClientRawRequest( - clientRawRequest.endpoint, - clientRawRequest.body, - clientRawRequest.headers - ); - } + // 0. Log client raw request (before format conversion) — redacts video transcript + // cues in the logged copy only; see videoBridgeSnapshotRedaction.ts. + logClientRawRequestRedacted(reqLogger, clientRawRequest, videoBridgeObserved); const reasoningRouteDecision = body && typeof body === "object" ? (body as Record)._omnirouteReasoningRouteTrace diff --git a/src/lib/guardrails/videoBridgeSnapshotRedaction.ts b/src/lib/guardrails/videoBridgeSnapshotRedaction.ts new file mode 100644 index 0000000000..8738b47cfc --- /dev/null +++ b/src/lib/guardrails/videoBridgeSnapshotRedaction.ts @@ -0,0 +1,135 @@ +/** + * #12150 P2 surface 1 (the dominant transcript-retention leak): structured redaction of + * video transcript fields on the CLIENT-REQUEST SNAPSHOT that lands in the detailed-log + * artifact. + * + * `clientRawRequest.body` (src/sse/handlers/chat/clientRawRequest.ts::buildClientRawRequest) + * is a bounded clone of the client's ORIGINAL request, captured BEFORE the guardrail chain + * runs, and persisted verbatim by `reqLogger.logClientRawRequest` + * (open-sse/handlers/chatCore.ts). Because it predates the video-bridge guardrail's own + * description redaction (#12150 P1 — see `describeVideoPart`'s `descriptionRedacted` in + * videoBridgeHelpers.ts), it still carries the client's raw `transcript` / `audioTranscript` + * cue text on any video part. This module redacts THAT COPY ONLY: the body sent to the + * provider and the response returned to the client are never touched here. + * + * Deliberately a standalone, dependency-light module — NOT part of videoBridgeHelpers.ts, + * which pulls in the frame-extraction broker client, audio/video fusion, contact-sheet + * composition and `sharp` for real video processing. The chat request hot path statically + * imports whatever module owns the `logClientRawRequest` call site on every request + * (video or not), so keeping this redaction free of that dependency chain matters for cold + * start and blast radius. + * + * The field walk mirrors `extractVideoParts` (videoBridgeHelpers.ts): for each content part, + * the candidate objects are the part itself, its `video_url` sub-object, and its `source` + * sub-object (the same three checked there) — but this walk is deliberately WIDER: any of + * those objects carrying a `transcript`/`audioTranscript` key gets redacted regardless of + * the part's `type`/shape. Those two field names are video-cue-only in this codebase's + * request contract, so matching on field presence rather than a shape allowlist is strictly + * safer (fails closed on an unusual or future video shape instead of silently skipping it). + * Redaction is a structured field substitution, not a scan over rendered text, so it cannot + * be bypassed by adversarial cue content (see the discarded regex approach recorded in the + * #12150 design doc, `_tasks/superpowers/specs/2026-09-01-video-transcript-retention-design.md`). + */ + +// Kept as a local literal (not imported from videoBridgeHelpers.ts) for the reason in the +// file header above. Equality with the canonical `VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER` +// export is enforced by a drift test in +// tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts. +const REDACTION_PLACEHOLDER = "[redacted-video-transcript]"; + +const TRANSCRIPT_FIELD_NAMES = ["transcript", "audioTranscript"] as const; +const NESTED_SUBOBJECT_KEYS = ["video_url", "source"] as const; +const CONTAINER_KEYS = ["messages", "input"] as const; + +type UnknownRecord = Record; + +function isPlainRecord(value: unknown): value is UnknownRecord { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +/** + * Overwrites transcript field VALUES in place on `part` and its `video_url`/`source` + * sub-objects. Only ever called on a part that already lives inside the function's own + * `structuredClone`, never on caller-owned data. Keys are overwritten, never deleted, so + * downstream shape/observability (e.g. "this part had a transcript") is preserved. + */ +function redactTranscriptFieldsOnPart(part: unknown): void { + if (!isPlainRecord(part)) return; + const candidates: UnknownRecord[] = [part]; + for (const key of NESTED_SUBOBJECT_KEYS) { + const nested = part[key]; + if (isPlainRecord(nested)) candidates.push(nested); + } + for (const candidate of candidates) { + for (const field of TRANSCRIPT_FIELD_NAMES) { + if (candidate[field] !== undefined) { + candidate[field] = REDACTION_PLACEHOLDER; + } + } + } +} + +function redactContentArray(content: unknown): void { + if (!Array.isArray(content)) return; + for (const part of content) { + redactTranscriptFieldsOnPart(part); + } +} + +/** `messages` (Chat Completions) or `input` (Responses API) — either container shape. */ +function redactContainer(container: unknown): void { + if (!Array.isArray(container)) return; + for (const message of container) { + if (!isPlainRecord(message)) continue; + redactContentArray(message.content); + } +} + +/** + * Returns a NEW structure with every video transcript cue field value replaced by the + * redaction placeholder. Never mutates `body` — the caller (chatCore.ts) must keep passing + * the untouched original to translation/dispatch/response. A non-object `body`, or one with + * neither `messages` nor `input`, or with video parts that carry no transcript field, is + * returned as an equivalent (cloned) structure with nothing to change. + */ +export function redactVideoTranscriptFieldsForLog(body: unknown): unknown { + if (!isPlainRecord(body)) return body; + const cloned = structuredClone(body) as UnknownRecord; + for (const key of CONTAINER_KEYS) { + redactContainer(cloned[key]); + } + return cloned; +} + +interface ClientRawRequestLike { + endpoint: unknown; + body: unknown; + headers?: unknown; +} + +interface RequestLoggerLike { + logClientRawRequest: (endpoint: unknown, body: unknown, headers?: unknown) => void; +} + +/** + * Call-site wrapper for `reqLogger.logClientRawRequest` (chatCore.ts's "0. Log client raw + * request" step): keeps the null-check and the observed/redacted guard out of chatCore.ts, + * which is a size-frozen file (`config/quality/file-size-baseline.json`) — this owns the + * redaction, so it owns the one guarded call site that applies it. Behavior is identical to + * the inline block it replaces: a non-observed request logs `clientRawRequest.body` by the + * exact same reference (no clone); an observed one logs the redacted clone. + */ +export function logClientRawRequestRedacted( + reqLogger: RequestLoggerLike, + clientRawRequest: ClientRawRequestLike | null | undefined, + videoBridgeObserved: boolean +): void { + if (!clientRawRequest) return; + reqLogger.logClientRawRequest( + clientRawRequest.endpoint, + videoBridgeObserved + ? redactVideoTranscriptFieldsForLog(clientRawRequest.body) + : clientRawRequest.body, + clientRawRequest.headers + ); +} diff --git a/tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts b/tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts new file mode 100644 index 0000000000..3f48bee7dc --- /dev/null +++ b/tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts @@ -0,0 +1,207 @@ +// #12150 P2 surface 1 (the dominant transcript-retention leak): pure-helper coverage for +// redactVideoTranscriptFieldsForLog — the structured redaction applied to the RAW +// client-request snapshot (clientRawRequest.body) before it is persisted by +// reqLogger.logClientRawRequest (open-sse/handlers/chatCore.ts). See +// src/lib/guardrails/videoBridgeSnapshotRedaction.ts for the full design rationale +// (deliberately dependency-light; field-presence match rather than a shape allowlist). +import assert from "node:assert/strict"; +import test from "node:test"; + +import { redactVideoTranscriptFieldsForLog } from "../../../src/lib/guardrails/videoBridgeSnapshotRedaction.ts"; +// Heavy import is fine here (test only, never in the production module under test) — used +// solely to prove the local placeholder literal never drifts from the canonical P1 constant. +import { VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER } from "../../../src/lib/guardrails/videoBridgeHelpers.ts"; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + return value as JsonRecord; +} + +function contentAt( + body: unknown, + container: "messages" | "input", + messageIndex: number +): JsonRecord[] { + const messages = asRecord(body)[container] as JsonRecord[]; + return messages[messageIndex].content as JsonRecord[]; +} + +test("redacts transcript and audioTranscript directly on a video part (messages container)", () => { + const body = { + model: "gpt-x", + messages: [ + { role: "system", content: "sys" }, + { + role: "user", + content: [ + { type: "text", text: "look at this video" }, + { + type: "input_video", + video_url: "https://example.com/clip.mp4", + transcript: { cues: [{ text: "secret words", startSeconds: 0, endSeconds: 2 }] }, + audioTranscript: { cues: [{ text: "audio secret", startSeconds: 0, endSeconds: 1 }] }, + }, + ], + }, + ], + }; + + const result = redactVideoTranscriptFieldsForLog(body); + assert.notEqual(result, body, "must return a new structure, not the same reference"); + + const videoPart = contentAt(result, "messages", 1)[1]; + assert.equal(videoPart.transcript, "[redacted-video-transcript]"); + assert.equal(videoPart.audioTranscript, "[redacted-video-transcript]"); + // The video ref itself and the sibling non-video part must survive untouched. + assert.equal(videoPart.video_url, "https://example.com/clip.mp4"); + assert.equal(contentAt(result, "messages", 1)[0].text, "look at this video"); + assert.equal(asRecord(result).messages, asRecord(result).messages); // sanity: still an array + + const serialized = JSON.stringify(result); + assert.ok(!serialized.includes("secret words"), "raw video transcript must not survive"); + assert.ok(!serialized.includes("audio secret"), "raw audio transcript must not survive"); +}); + +test("redacts a transcript nested under the video_url sub-object", () => { + const body = { + messages: [ + { + role: "user", + content: [ + { + type: "video_url", + video_url: { + url: "https://example.com/nested.mp4", + transcript: { cues: [{ text: "nested secret" }] }, + }, + }, + ], + }, + ], + }; + + const result = redactVideoTranscriptFieldsForLog(body); + const part = contentAt(result, "messages", 0)[0]; + const videoUrl = part.video_url as JsonRecord; + assert.equal(videoUrl.transcript, "[redacted-video-transcript]"); + assert.equal(videoUrl.url, "https://example.com/nested.mp4"); + assert.ok(!JSON.stringify(result).includes("nested secret")); +}); + +test("redacts a transcript nested under the source sub-object (video_source shape)", () => { + const body = { + messages: [ + { + role: "user", + content: [ + { + type: "video_source", + source: { + type: "url", + url: "https://example.com/source.mp4", + audioTranscript: { cues: [{ text: "source secret" }] }, + }, + }, + ], + }, + ], + }; + + const result = redactVideoTranscriptFieldsForLog(body); + const part = contentAt(result, "messages", 0)[0]; + const source = part.source as JsonRecord; + assert.equal(source.audioTranscript, "[redacted-video-transcript]"); + assert.equal(source.url, "https://example.com/source.mp4"); + assert.ok(!JSON.stringify(result).includes("source secret")); +}); + +test("covers the input container (Responses API shape)", () => { + const body = { + model: "gpt-x", + input: [ + { + role: "user", + content: [ + { + type: "input_video", + video_url: "https://example.com/input.mp4", + transcript: { cues: [{ text: "input secret" }] }, + }, + ], + }, + ], + }; + + const result = redactVideoTranscriptFieldsForLog(body); + const part = contentAt(result, "input", 0)[0]; + assert.equal(part.transcript, "[redacted-video-transcript]"); + assert.ok(!JSON.stringify(result).includes("input secret")); +}); + +test("does not mutate the input", () => { + const body = { + messages: [ + { + role: "user", + content: [ + { + type: "input_video", + video_url: "https://example.com/clip.mp4", + transcript: { cues: [{ text: "secret words" }] }, + audioTranscript: { cues: [{ text: "audio secret" }] }, + }, + ], + }, + ], + }; + const before = JSON.parse(JSON.stringify(body)); + + redactVideoTranscriptFieldsForLog(body); + + assert.deepEqual(body, before, "input object must be byte-identical after the call"); +}); + +test("a non-video body is returned unchanged", () => { + const body = { + model: "gpt-x", + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "hello, no video here" }, + ], + }; + + const result = redactVideoTranscriptFieldsForLog(body); + assert.deepEqual(result, body); +}); + +test("a body with a video part but no transcript field is unchanged", () => { + const body = { + messages: [ + { + role: "user", + content: [{ type: "input_video", video_url: "https://example.com/no-transcript.mp4" }], + }, + ], + }; + + const result = redactVideoTranscriptFieldsForLog(body); + assert.deepEqual(result, body); +}); + +test("the redaction placeholder matches the canonical P1 constant (no drift)", () => { + const body = { + messages: [ + { + role: "user", + content: [ + { type: "input_video", video_url: "https://example.com/clip.mp4", transcript: "raw" }, + ], + }, + ], + }; + + const result = redactVideoTranscriptFieldsForLog(body); + const part = contentAt(result, "messages", 0)[0]; + assert.equal(part.transcript, VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER); +}); diff --git a/tests/unit/video-bridge-log-redaction.test.ts b/tests/unit/video-bridge-log-redaction.test.ts index d190db80c4..268102d246 100644 --- a/tests/unit/video-bridge-log-redaction.test.ts +++ b/tests/unit/video-bridge-log-redaction.test.ts @@ -26,6 +26,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { logClientRawRequestRedacted } from "../../src/lib/guardrails/videoBridgeSnapshotRedaction.ts"; + const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-video-log-redaction-test-")); process.env.DATA_DIR = testDataDir; @@ -265,3 +267,79 @@ test("Scenario A (adversarial review): a message prepended AFTER the guardrail b "the prepended system message must be untouched" ); }); + +// #12150 P2 surface 1 (the dominant transcript-retention leak): the RAW client-request +// snapshot passed to reqLogger.logClientRawRequest (open-sse/handlers/chatCore.ts's +// "0. Log client raw request" step) is a DIFFERENT sink from persistAttemptLogs above — +// it is captured before the guardrail chain even runs, so it carries the client's raw +// `transcript`/`audioTranscript` FIELDS on a structured video part, not a flattened +// description string. Importing the real chatCore.ts here would pull the full +// request-pipeline dependency graph (executors, providers, combo routing, DB-backed +// settings, ...) into the test just to reach one guarded call a few hundred lines into +// a 5900+ line handler, for no additional proof beyond what's below — so this calls the +// REAL exported `logClientRawRequestRedacted` (the exact function chatCore.ts's call site +// invokes, post file-size-refactor) against a fake logClientRawRequest. The pure redaction +// helper itself has its own thorough suite in +// tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts. +function fakeReqLogger() { + const calls: unknown[] = []; + return { + calls, + logClientRawRequest(_endpoint: unknown, body: unknown, _headers?: unknown) { + calls.push(body); + }, + }; +} + +test("surface 2 (raw snapshot): the fake logClientRawRequest receives a redacted snapshot only when videoBridgeObserved is true", () => { + const rawBody = { + model: "openai/gpt-x", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "look at this video" }, + { + type: "input_video", + video_url: "https://example.com/clip.mp4", + transcript: { cues: [{ text: SECRET, startSeconds: 0, endSeconds: 2 }] }, + }, + ], + }, + ], + }; + const clientRawRequest = { endpoint: "/v1/chat/completions", body: rawBody, headers: {} }; + + const observedLogger = fakeReqLogger(); + logClientRawRequestRedacted(observedLogger, clientRawRequest, true); + const observedSnapshot = observedLogger.calls[0]; + assert.ok( + !JSON.stringify(observedSnapshot).includes(SECRET), + "an observed request must not log the raw transcript" + ); + assert.notEqual( + observedSnapshot, + rawBody, + "the observed path must log a redacted CLONE, not the original reference" + ); + assert.ok( + JSON.stringify(rawBody).includes(SECRET), + "clientRawRequest.body itself must stay untouched for every other consumer (translation/dispatch)" + ); + + const nonObservedLogger = fakeReqLogger(); + logClientRawRequestRedacted(nonObservedLogger, clientRawRequest, false); + assert.equal( + nonObservedLogger.calls[0], + rawBody, + "the non-observed path must log the exact same object reference — byte-identical, no clone" + ); + + const skippedLogger = fakeReqLogger(); + logClientRawRequestRedacted(skippedLogger, null, true); + assert.equal( + skippedLogger.calls.length, + 0, + "a missing clientRawRequest must not call logClientRawRequest at all (mirrors the old if-guard)" + ); +}); From 3740839e2a6741ec9e4bdae5b4ce976fdb6a35eb Mon Sep 17 00:00:00 2001 From: opensource-elearning <159253500+opensource-elearning@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:50:58 +0530 Subject: [PATCH 14/47] fix(combo): fall back to full pool when collapsed sole survivor is context-too-small (#12278) Validado em lote sobre o tip de release/v3.8.51: boardou sem conflito, typecheck:core limpo e 17/17 em tests/unit/combo-context-window-filter.test.ts. Obrigado, @opensource-elearning. --- open-sse/services/combo/comboStructure.ts | 44 +++++++-- .../unit/combo-context-window-filter.test.ts | 92 +++++++++++++++++++ 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index 137561fbaa..9065e123b2 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -807,16 +807,48 @@ export function filterTargetsByRequestCompatibility( return []; } + // #12273: a sole survivor whose catalog window is known-too-small is a + // guaranteed context_length_exceeded. Restore the remaining pool so combo.ts + // can still try larger-context targets. Unknown context (`null`) is advisory + // and must not resurrect hard-rejected targets (vision / output / tools). + if ( + compatible.length === 1 && + (targetReasons.get(compatible[0]) || []).includes("context_window") + ) { + // #8332: never restore a confirmed-non-vision target onto an image request. + const restored = requirements.requiresVision + ? targets.filter((target) => !isVisionIncompatibleTarget(target, requirements)) + : targets; + if (restored.length > compatible.length) { + log.warn( + "COMBO", + `${label}: single compatible target ${compatible[0].modelStr} has known context too small for ${requirements.requiredContextTokens} token request; falling back to full pool (#12273)` + ); + return restored; + } + } + log.info( "COMBO", `${label}: kept ${compatible.length}/${targets.length} targets for request requirements` ); - log.debug?.( - "COMBO", - `${label}: rejected targets ${rejected - .map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`) - .join(", ")}` - ); + // #12273: When pool collapses significantly, log rejection reasons at info + // level so the cause is diagnosable without enabling debug logging. + if (compatible.length <= 2 && targets.length > 4) { + log.info( + "COMBO", + `${label}: rejected targets ${rejected + .map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`) + .join(", ")}` + ); + } else { + log.debug?.( + "COMBO", + `${label}: rejected targets ${rejected + .map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`) + .join(", ")}` + ); + } return compatible; } diff --git a/tests/unit/combo-context-window-filter.test.ts b/tests/unit/combo-context-window-filter.test.ts index a78c3d5ba5..74d2646f3e 100644 --- a/tests/unit/combo-context-window-filter.test.ts +++ b/tests/unit/combo-context-window-filter.test.ts @@ -450,3 +450,95 @@ test("without an override the small-catalog target is ordered last for the large ["unit-override/big", "unit-override/capped"] ); }); + +// #12273: real Claude Code requests always carry `tools`, and the auto/coding +// pool mixes coding-capable providers with providers whose catalog marks +// toolCalling=false. Those non-coding targets are HARD-rejected (tools), so the +// compat filter can collapse the whole pool to a single too-small-context +// coding model (e.g. mimo-v2.5-free at 200k) for a much larger request — the +// larger-context model was never assembled into the candidate pool. Routing to +// that sole survivor is a guaranteed context_length_exceeded, so the filter +// must fall back to the full pool instead of silently pinning the request. +test("#12273 single known-too-small survivor falls back to the full pool", () => { + saveModelsDevCapabilities({ + "unit-collapse": { + small: capabilityEntry(200_000), + }, + "unit-noncoding": { + nocoder: { ...capabilityEntry(1_000_000), tool_call: false }, + }, + }); + const body = { + ...bigContextBody(300_000), + tools: [{ type: "function" }], // Claude Code always sends tools + }; + + const out = filterTargetsByRequestCompatibility( + [target("unit-collapse/small"), target("unit-noncoding/nocoder")], + body, + noopLog + ); + + // nocoder is hard-rejected (toolCalling=false); small (200k) is the only + // compatible survivor but is known to be too small for a 300k request, so the + // filter returns the full pool rather than dispatch to a guaranteed failure. + assert.deepEqual( + out.map((entry) => entry.modelStr), + ["unit-collapse/small", "unit-noncoding/nocoder"] + ); +}); + +// Guard against regression: when the single survivor's window DOES fit the +// request, the filter still collapses (existing behavior preserved). +test("#12273 single compatible target that fits is still collapsed", () => { + saveModelsDevCapabilities({ + "unit-collapse": { + big: capabilityEntry(1_000_000), + }, + "unit-noncoding": { + nocoder: { ...capabilityEntry(1_000_000), tool_call: false }, + }, + }); + const body = { + ...bigContextBody(300_000), + tools: [{ type: "function" }], + }; + + const out = filterTargetsByRequestCompatibility( + [target("unit-collapse/big"), target("unit-noncoding/nocoder")], + body, + noopLog + ); + + // big (1M) fits the 300k request, so the collapse is legitimate. + assert.deepEqual( + out.map((entry) => entry.modelStr), + ["unit-collapse/big"] + ); +}); + +// #12278: unknown context is advisory, not "known too small". Collapsing to a +// single survivor whose context limit is unknown must NOT restore hard-rejected +// targets (output_tokens here; vision is covered by combo-vision-aware-routing). +test("#12273 unknown-context sole survivor does not restore hard-rejected targets", () => { + saveModelsDevCapabilities({ + "unit-output": { + tiny: capabilityEntryWithLimits(128_000, 128_000, 4096), + }, + }); + const body = { + messages: [{ role: "user", content: "hello" }], + max_tokens: 32_000, + }; + + const out = filterTargetsByRequestCompatibility( + [target("unit-unknown/mystery"), target("unit-output/tiny")], + body, + noopLog + ); + + assert.deepEqual( + out.map((entry) => entry.modelStr), + ["unit-unknown/mystery"] + ); +}); From 8df944cd4670171ebca2f0b003f5955fca3d2781 Mon Sep 17 00:00:00 2001 From: opensource-elearning <159253500+opensource-elearning@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:54:32 +0530 Subject: [PATCH 15/47] feat(browser): adopt Obscura as primary headless browser engine with Chromium fallback (#12286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado sobre o tip de release/v3.8.51 após reconciliar o base-drift. O pool de browsers ganhou um caminho **headed** (`headedBrowser`/`headedLaunching`, `resolvePlainBrowserLaunchOptions`, estado de launch por modo) depois que esta branch forkou. O PR reescrevia `launchBrowser()` no modelo de browser único contra o qual foi escrito, o que teria **removido o suporte headed**. Em vez disso, reapliquei a preferência pelo Obscura dentro do ramo headless de `launchBrowserInstance()`, à frente do cloakbrowser e do Chromium puro — um browser headed precisa ser um Chromium com janela real, então a preferência de engine é só do caminho headless. `state.engine` alimenta `isStealth` e `getBrowserPoolStatus()`, e o shutdown zera o engine sem matar o servidor Obscura compartilhado (dono: `./obscura.ts`). typecheck:core limpo e 3/3 em tests/unit/obscura-integration.test.ts. Obrigado, @opensource-elearning. --- .env.example | 9 + docs/reference/ENVIRONMENT.md | 3 + open-sse/executors/cloudflare-playground.ts | 23 ++- open-sse/services/browserPool.ts | 31 +++- open-sse/services/obscura.ts | 163 ++++++++++++++++++ tests/unit/obscura-integration.test.ts | 155 +++++++++++++++++ .../webpack-create-require-warning.test.ts | 4 + 7 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 open-sse/services/obscura.ts create mode 100644 tests/unit/obscura-integration.test.ts diff --git a/.env.example b/.env.example index 4e6438b593..81b03d5b11 100644 --- a/.env.example +++ b/.env.example @@ -1524,6 +1524,15 @@ CURSOR_USER_AGENT="Cursor/3.4" # request into the browser-backed path. # OMNIROUTE_BROWSER_POOL=on # WEB_COOKIE_USE_BROWSER=0 +# Obscura (https://github.com/h4ckf0r0day/obscura) is the primary headless +# engine: a lightweight CDP server the pool and cloudflare-playground connect +# to before falling back to Chromium. Unset OBSCURA_BIN to auto-detect from +# PATH; set OBSCURA_CDP_ENDPOINT to reuse an already-running Obscura instead +# of spawning one; set OBSCURA_PORT to pin the spawned serve port. +# Used by: open-sse/services/obscura.ts +# OBSCURA_BIN= +# OBSCURA_CDP_ENDPOINT= +# OBSCURA_PORT= # ── Kimi Web (international kimi.ai Connect-RPC) ── # Used by: open-sse/executors/kimi-web.ts. Override the base/chat URLs only if diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index ace16cf6c9..9dd2fff9f0 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -776,6 +776,9 @@ REQUEST_TIMEOUT_MS (global override) | `OMNIROUTE_NOTION_TLS_TIMEOUT_MS` | `30000` | Native wreq-js request timeout (`notionTlsClient.ts`); `notion-web` raises it per request to `180000` for long generations. | | `OMNIROUTE_NOTION_TLS_GRACE_MS` | `10000` | Absolute JS hard-deadline grace added on top of the native timeout. | | `OMNIROUTE_BROWSER_POOL` | `on` | Shared Playwright browser pool for browser-backed web-cookie chat (`browserPool.ts`); set `off` to disable. | +| `OBSCURA_BIN` | `auto-detect` | Path to the `obscura` binary used as the primary engine by the browser pool and Cloudflare Playground executor (`open-sse/services/obscura.ts`); auto-detected from the system PATH when unset. | +| `OBSCURA_CDP_ENDPOINT` | _(unset)_ | Point at an already-running Obscura (`http://host:port`) instead of spawning one; the module does not own that process (`open-sse/services/obscura.ts`). | +| `OBSCURA_PORT` | `random free port` | Explicit port for the spawned `obscura serve`; a free port is chosen automatically when unset (`open-sse/services/obscura.ts`). | | `WEB_COOKIE_USE_BROWSER` | `0` | Opt a web-cookie chat request into the browser-backed path (`browserBackedChat.ts`); `1` to enable. | | `KIMI_WEB_BASE_URL` | `https://www.kimi.ai` | Base URL for the Kimi Web (international kimi.ai Connect-RPC) executor (`kimi-web.ts`); override only for mirror/proxy endpoints. | | `KIMI_WEB_CHAT_URL` | `/apiv2/kimi.gateway.chat.v1.ChatService/Chat` | Full chat endpoint for the Kimi Web executor (`kimi-web.ts`). | diff --git a/open-sse/executors/cloudflare-playground.ts b/open-sse/executors/cloudflare-playground.ts index ba309f1eed..249e069da3 100644 --- a/open-sse/executors/cloudflare-playground.ts +++ b/open-sse/executors/cloudflare-playground.ts @@ -36,6 +36,7 @@ import { randomUUID } from "crypto"; import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; +import { connectObscuraBrowser } from "../services/obscura.ts"; import type { Browser, Page } from "playwright"; export const PLAYGROUND_URL = "https://playground.ai.cloudflare.com/"; @@ -296,14 +297,22 @@ export class PlaywrightCfTransport implements CfTransport { config: CfTransportConfig ): Promise<{ ok: true } | { ok: false; status: number; message: string }> { try { + // #12274: prefer the shared Obscura browser (browser-grade TLS fingerprint + // on the WS upgrade, ~30MB) over a full Chromium per request; fall back to + // a direct Chromium launch when Obscura is unavailable. + const obscura = await connectObscuraBrowser(); const playwright = await importPlaywright(); - const executablePath = - this.chromeExecutablePath ?? process.env.CLOUDFLARE_PLAYGROUND_CHROME_PATH; - this.browser = await playwright.chromium.launch({ - ...(executablePath ? { executablePath } : {}), - headless: true, - args: BROWSER_ARGS, - }); + if (obscura) { + this.browser = obscura.browser; + } else { + const executablePath = + this.chromeExecutablePath ?? process.env.CLOUDFLARE_PLAYGROUND_CHROME_PATH; + this.browser = await playwright.chromium.launch({ + ...(executablePath ? { executablePath } : {}), + headless: true, + args: BROWSER_ARGS, + }); + } const context = await this.browser.newContext({ userAgent: PLAYGROUND_UA }); const page = await context.newPage(); this.page = page; diff --git a/open-sse/services/browserPool.ts b/open-sse/services/browserPool.ts index bcab2dc174..51a1abb44b 100644 --- a/open-sse/services/browserPool.ts +++ b/open-sse/services/browserPool.ts @@ -26,6 +26,8 @@ import { Buffer } from "node:buffer"; +import { connectObscuraBrowser } from "./obscura.ts"; + type Browser = import("playwright").Browser; type BrowserContext = import("playwright").BrowserContext; type Page = import("playwright").Page; @@ -86,8 +88,12 @@ function createBrowserPoolMetrics(): BrowserPoolMetrics { }; } +type PoolEngine = "obscura" | "cloakbrowser" | "chromium"; + interface PoolState { browser: Browser | null; + /** Engine backing the headless browser, for metrics and stealth detection. */ + engine: PoolEngine | null; headedBrowser: Browser | null; contexts: Map; pendingContexts: Map>; @@ -110,6 +116,7 @@ const DEFAULT_USER_AGENT = const state: PoolState = { browser: null, + engine: null, headedBrowser: null, contexts: new Map(), pendingContexts: new Map(), @@ -288,13 +295,26 @@ async function launchBrowserInstance( options: BrowserPoolContextOptions, headless: boolean ): Promise { + // A headed browser must be a real windowed Chromium, so the engine + // preference below applies to the headless path only. if (!headless) { const { chromium } = await import("playwright"); return chromium.launch(resolvePlainBrowserLaunchOptions(options)); } + // #12274: prefer Obscura (lightweight, browser-grade CDP) over a full + // Chromium; fall back to cloakbrowser, then plain Chromium. Obscura's + // lifecycle (one shared `obscura serve` per process) lives in ./obscura.ts, + // so executors like cloudflare-playground reuse the same server. + const obscura = await connectObscuraBrowser(); + if (obscura) { + state.engine = "obscura"; + return obscura.browser; + } + const cloakLaunch = await resolveCloakLaunch(); if (cloakLaunch) { + state.engine = "cloakbrowser"; return cloakLaunch({ headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"], @@ -303,6 +323,7 @@ async function launchBrowserInstance( // Fallback: plain Playwright. Works for Claude web (cookie-only auth) but // DDG's VQD challenge will detect this Chromium build. + state.engine = "chromium"; const { chromium } = await import("playwright"); return chromium.launch(resolvePlainBrowserLaunchOptions(options)); } @@ -471,7 +492,7 @@ export async function acquireBrowserContext( launchBrowser(options), resolveBrowserContextProxy(key, options), ]); - const isStealth = headless && state.cloakLaunch !== null; + const isStealth = headless && (state.engine === "obscura" || state.cloakLaunch !== null); const context = await browser.newContext({ userAgent: options.userAgent || DEFAULT_USER_AGENT, locale: options.locale || "en-US", @@ -580,6 +601,10 @@ export async function shutdownPool(reason: string): Promise { } state.launching = null; state.headedLaunching = null; + // #12274: the shared Obscura server is owned by ./obscura.ts and reused by + // executors (cloudflare-playground), so closing the pool's CDP connection is + // enough — never kill the server here. + state.engine = null; state.lastActivity = Date.now(); // Avoid unused-parameter lint: log reason via debug if anyone hooks // process.on('exit') and prints state. @@ -590,6 +615,7 @@ export function getBrowserPoolStatus(): { enabled: boolean; contexts: number; browserRunning: boolean; + engine: PoolEngine | null; stealthAvailable: boolean; lastActivityAgoMs: number; } { @@ -597,7 +623,8 @@ export function getBrowserPoolStatus(): { enabled: isPoolEnabled(), contexts: state.contexts.size, browserRunning: state.browser !== null || state.headedBrowser !== null, - stealthAvailable: state.cloakLaunch !== null, + engine: state.engine, + stealthAvailable: state.engine === "obscura" || state.cloakLaunch !== null, lastActivityAgoMs: state.lastActivity === 0 ? -1 : Date.now() - state.lastActivity, }; } diff --git a/open-sse/services/obscura.ts b/open-sse/services/obscura.ts new file mode 100644 index 0000000000..23c4917214 --- /dev/null +++ b/open-sse/services/obscura.ts @@ -0,0 +1,163 @@ +/** + * obscura.ts — Shared Obscura browser engine (#12274). + * + * Obscura (https://github.com/h4ckf0r0day/obscura) is a lightweight Rust + * headless browser (~30MB resident) that speaks the Chrome DevTools Protocol. + * Playwright's `chromium.connectOverCDP` drives it like a real Chrome, so the + * browser pool and the cloudflare-playground executor can both use it without + * holding a 150-400MB Chromium process. + * + * Lifecycle: one Obscura `serve` process is spawned lazily on first use and + * shared for the server's lifetime. Callers receive a fresh CDP connection on + * demand; closing the connection does not stop the shared server. Set + * OBSCURA_CDP_ENDPOINT to point at an already-running Obscura instead of + * spawning one here (the process is then not owned by this module). The + * module is also disabled entirely when OMNIROUTE_BROWSER_POOL=off. + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { createServer } from "node:net"; + +export interface ObscuraConnection { + /** Playwright Browser connected over CDP to the shared Obscura server. */ + browser: import("playwright").Browser; + /** The spawned `obscura serve` process, or null when an external endpoint is used. */ + child: ChildProcess | null; +} + +let shared: { child: ChildProcess | null; endpoint: string } | null = null; +let starting: Promise<{ child: ChildProcess | null; endpoint: string } | null> | null = null; + +export function isObscuraUsable(): boolean { + const flag = process.env.OMNIROUTE_BROWSER_POOL; + if (flag === undefined) return true; + return flag !== "off" && flag !== "0" && flag !== "false"; +} + +function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.once("error", reject); + srv.listen(0, "127.0.0.1", () => { + const address = srv.address(); + srv.close(() => { + if (address && typeof address === "object") resolve(address.port); + else reject(new Error("obscura: could not allocate a free port")); + }); + }); + }); +} + +async function obscuraBinaryPath(): Promise { + const bin = process.env.OBSCURA_BIN; + if (bin) return bin; + const { resolve } = await import("node:path"); + const { existsSync, accessSync, constants } = await import("node:fs"); + const dirs = (process.env.PATH || "").split(":"); + for (const dir of dirs) { + const candidate = resolve(dir, "obscura"); + try { + accessSync(candidate, constants.X_OK); + if (existsSync(candidate)) return candidate; + } catch { + /* not executable here — keep looking */ + } + } + return null; +} + +async function waitForCdpEndpoint(endpoint: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 1500); + // Probe /json/version, not the base URL: Obscura's HTTP server answers + // the CDP info route, while a bare GET to "/" never completes a response. + const probe = endpoint.replace(/^ws/, "http").replace(/\/$/, "") + "/json/version"; + const res = await fetch(probe, { signal: controller.signal }); + clearTimeout(timer); + if (res.ok) return true; + } catch { + /* not up yet */ + } + await new Promise((r) => setTimeout(r, 250)); + } + return false; +} + +/** Ensure the shared Obscura server is up; returns its endpoint or null. */ +export async function ensureObscuraServer(): Promise<{ + child: ChildProcess | null; + endpoint: string; +} | null> { + if (!isObscuraUsable()) return null; + if (shared) return shared; + if (starting) return starting; + starting = (async () => { + const endpoint = process.env.OBSCURA_CDP_ENDPOINT; + if (endpoint) { + shared = { child: null, endpoint }; + return shared; + } + const bin = await obscuraBinaryPath(); + if (!bin) return null; + const port = Number(process.env.OBSCURA_PORT) || (await findFreePort()); + const child = spawn(bin, ["serve", "--port", String(port), "--host", "127.0.0.1"], { + stdio: ["ignore", "ignore", "pipe"], + }); + child.stderr?.on("data", () => {}); // obscura logs verbosely — swallow + const endpointForServer = `http://127.0.0.1:${port}`; + // A bad binary path (or a binary that cannot serve) must not hold the + // readiness wait for the full timeout: bail as soon as the child exits + // (or fails to spawn at all — 'exit' alone misses an ENOENT 'error'). + const died = new Promise((resolve) => { + child.once("exit", () => resolve(true)); + child.once("error", () => resolve(true)); + }); + const ready = await Promise.race([ + waitForCdpEndpoint(endpointForServer, 30_000), + died.then(() => false as const), + ]); + if (ready !== true) { + child.kill("SIGKILL"); + return null; + } + shared = { child, endpoint: endpointForServer }; + return shared; + })(); + try { + return await starting; + } finally { + starting = null; + } +} + +/** + * Connect Playwright to the shared Obscura server. Returns null when Obscura + * is disabled, not installed, or the server could not start (callers fall + * back to their previous Chromium strategy). + */ +export async function connectObscuraBrowser(): Promise { + const server = await ensureObscuraServer(); + if (!server) return null; + try { + const { chromium } = await import("playwright"); + const browser = await chromium.connectOverCDP(server.endpoint); + return { browser, child: server.child }; + } catch { + return null; + } +} + +/** Caution: this terminates the shared `obscura serve` process (process-lifetime anyway). */ +export function killSharedObscuraServer(): void { + if (shared?.child) { + try { + shared.child.kill("SIGKILL"); + } catch { + /* ignore */ + } + } + shared = null; +} diff --git a/tests/unit/obscura-integration.test.ts b/tests/unit/obscura-integration.test.ts new file mode 100644 index 0000000000..67a3006d79 --- /dev/null +++ b/tests/unit/obscura-integration.test.ts @@ -0,0 +1,155 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { createServer } from "node:net"; + +import { + connectObscuraBrowser, + ensureObscuraServer, + killSharedObscuraServer, + isObscuraUsable, +} from "../../open-sse/services/obscura.ts"; + +// #12274 — Obscura-first browser engine. The shared server is process-lifetime; +// each test resets it so suites run independently. When `obscura` is not +// installed the live tests skip; the null-return path is still covered. + +const BIN_RESULT = spawnSync("which", ["obscura"], { encoding: "utf8" }); +const HAS_OBSCURA = BIN_RESULT.status === 0 && BIN_RESULT.stdout.trim().length > 0; + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.once("error", reject); + srv.listen(0, "127.0.0.1", () => { + const address = srv.address(); + srv.close(() => { + if (address && typeof address === "object") resolve(address.port); + else reject(new Error("no free port")); + }); + }); + }); +} + +async function waitForCdp(endpoint: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 1500); + // Probe /json/version (Obscura's bare "/" never completes a response). + const probe = endpoint.replace(/^ws/, "http").replace(/\/$/, "") + "/json/version"; + const res = await fetch(probe, { signal: controller.signal }); + clearTimeout(timer); + if (res.ok) return true; + } catch { + /* not up yet */ + } + await new Promise((r) => setTimeout(r, 250)); + } + return false; +} + +describe("obscura engine", () => { + it("respects OMNIROUTE_BROWSER_POOL=off", () => { + const original = process.env.OMNIROUTE_BROWSER_POOL; + process.env.OMNIROUTE_BROWSER_POOL = "off"; + try { + assert.equal(isObscuraUsable(), false); + } finally { + if (original === undefined) delete process.env.OMNIROUTE_BROWSER_POOL; + else process.env.OMNIROUTE_BROWSER_POOL = original; + } + }); + + it("is enabled by default (no env var)", () => { + const original = process.env.OMNIROUTE_BROWSER_POOL; + delete process.env.OMNIROUTE_BROWSER_POOL; + try { + assert.equal(isObscuraUsable(), true); + } finally { + if (original !== undefined) process.env.OMNIROUTE_BROWSER_POOL = original; + } + }); + + it("returns null when the binary is absent or cannot start", async () => { + killSharedObscuraServer(); + const originalBin = process.env.OBSCURA_BIN; + const originalEndpoint = process.env.OBSCURA_CDP_ENDPOINT; + process.env.OBSCURA_BIN = "/nonexistent/obscura"; + delete process.env.OBSCURA_CDP_ENDPOINT; + try { + const server = await ensureObscuraServer(); + assert.equal(server, null); + } finally { + if (originalBin === undefined) delete process.env.OBSCURA_BIN; + else process.env.OBSCURA_BIN = originalBin; + if (originalEndpoint === undefined) delete process.env.OBSCURA_CDP_ENDPOINT; + else process.env.OBSCURA_CDP_ENDPOINT = originalEndpoint; + killSharedObscuraServer(); + } + }); + + it("round-trips a page through Obscura when installed", async (t) => { + killSharedObscuraServer(); + if (!HAS_OBSCURA) { + t.skip("obscura binary not installed"); + return; + } + try { + const connection = await connectObscuraBrowser(); + assert.ok(connection, "expected a live Obscura connection"); + const { browser } = connection; + const context = await browser.newContext({ userAgent: "obscura-integration-test" }); + const page = await context.newPage(); + await page.goto("https://example.com", { waitUntil: "domcontentloaded", timeout: 30000 }); + const title = await page.title(); + assert.equal(title, "Example Domain"); + await context.close(); + await browser.close(); + } finally { + killSharedObscuraServer(); + } + }); + + it("connects to an external endpoint without owning its process", async (t) => { + void t; + killSharedObscuraServer(); + if (!HAS_OBSCURA) { + t.skip("obscura binary not installed"); + return; + } + // Standalone server we own outside the module, referenced as "external". + const port = await freePort(); + const child = spawn( + process.env.OBSCURA_BIN ?? "obscura", + ["serve", "--port", String(port), "--host", "127.0.0.1"], + { stdio: ["ignore", "ignore", "pipe"] } + ); + const endpoint = `http://127.0.0.1:${port}`; + const ready = await waitForCdp(endpoint); + if (!ready) { + child.kill("SIGKILL"); + t.skip("external obscura server did not come up"); + return; + } + const originalBin = process.env.OBSCURA_BIN; + const originalEndpoint = process.env.OBSCURA_CDP_ENDPOINT; + process.env.OBSCURA_CDP_ENDPOINT = endpoint; + process.env.OBSCURA_BIN = "/nonexistent/obscura"; // force the external path + try { + const connection = await connectObscuraBrowser(); + assert.ok(connection); + assert.equal(connection.child, null, "external endpoint must not own a child process"); + assert.ok(connection.browser.version().length > 0); + await connection.browser.close(); + } finally { + if (originalBin === undefined) delete process.env.OBSCURA_BIN; + else process.env.OBSCURA_BIN = originalBin; + if (originalEndpoint === undefined) delete process.env.OBSCURA_CDP_ENDPOINT; + else process.env.OBSCURA_CDP_ENDPOINT = originalEndpoint; + child.kill("SIGKILL"); + killSharedObscuraServer(); + } + }); +}); diff --git a/tests/unit/webpack-create-require-warning.test.ts b/tests/unit/webpack-create-require-warning.test.ts index 7f454c614a..9c375c52bd 100644 --- a/tests/unit/webpack-create-require-warning.test.ts +++ b/tests/unit/webpack-create-require-warning.test.ts @@ -75,6 +75,10 @@ async function compileRuntimeRequireModules(): Promise { "sqlite-vec", "playwright", "wreq-js", + // browserPool.ts imports `./obscura.ts`. The isolated webpack compile + // has no repo tree, so treat the sibling as external instead of + // erroring "Can't resolve './obscura.ts'". + "./obscura.ts", ], externalsPresets: { node: true }, mode: "development", From e2e330a058a7913711b0d25548013ca73d2b4874 Mon Sep 17 00:00:00 2001 From: opensource-elearning <159253500+opensource-elearning@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:59:14 +0530 Subject: [PATCH 16/47] perf(stream): compile hot-path regexes once, bound token caches, fix quadratic buffering (#12179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado sobre o tip de `release/v3.8.51` após reconciliar quatro arquivos que driftaram. Em parte o tip já tinha absorvido a intenção deste branch por abstrações melhores, então mantive a forma do tip e trouxe os ganhos que ainda eram reais: - **`sseCollect.ts`** — o tip extraiu `stripObfuscationZeroWidth()` para `utils/zeroWidth.ts`, o que supera o `ZERO_WIDTH_RE` local (removido). A içada de `TEXTUAL_TOOL_CALL_RE` foi mantida: essa regex ainda estava inline num caminho quente. - **`resultMemo.ts`** — o `memoStore()` do tip devolve o clone armazenado para que o idiom comum `memoStore(k, r); return memoLookup(k)!` evite um segundo deep-clone de vários MB. Esse contrato foi preservado (o branch o revertia para `void`), e o round-trip `JSON.parse(JSON.stringify())` virou `structuredClone()` nas duas pontas — que era o ponto de performance real do branch. - **`browserPool.ts`** — o tip agora tem caminho headed e o engine Obscura (#12286). Ambos preservados, mais a varredura de TTL do `pendingContexts` deste branch, adaptada ao nome `poolKey` do tip. - **`executeAttempt.ts`** — mantido o comentário explicativo do tip. Também corrigi **quatro erros de typecheck que o branch introduzia**: `hasUnsupportedSignal` estava tipado `boolean` mas avaliava para `string | boolean`, e o fast-path de `extractUsage()` indexava `c.response`/`c.message` como `unknown`. `typecheck:core` limpo e **482/482** nos testes de antigravity + compressão na própria branch. Obrigado, @opensource-elearning. --- open-sse/executors/adapta-web.ts | 15 ++++- open-sse/executors/antigravity/sseCollect.ts | 9 ++- open-sse/executors/codex.ts | 8 ++- open-sse/executors/glm.ts | 7 +- open-sse/executors/tinycms.ts | 5 +- open-sse/executors/zcodeProtocol.ts | 42 ++++++++---- open-sse/handlers/responseSanitizer.ts | 18 +++++- open-sse/services/accountFallback.ts | 29 +++++---- open-sse/services/browserPool.ts | 22 +++++-- open-sse/services/compression/resultMemo.ts | 4 +- open-sse/services/gigachatAuth.ts | 26 +++++++- open-sse/services/responsesInputSanitizer.ts | 8 ++- open-sse/utils/composerToolCalls.ts | 25 +++++-- open-sse/utils/reasoningFields.ts | 68 ++++++++++++-------- open-sse/utils/responsesStreamHelpers.ts | 12 ++-- open-sse/utils/stream.ts | 16 +++-- open-sse/utils/streamHandler.ts | 18 ++++-- open-sse/utils/streamHelpers.ts | 19 ++++-- open-sse/utils/usageTracking.ts | 19 ++++++ package.json | 1 + 20 files changed, 275 insertions(+), 96 deletions(-) diff --git a/open-sse/executors/adapta-web.ts b/open-sse/executors/adapta-web.ts index 8c9ae571ea..da1b836002 100644 --- a/open-sse/executors/adapta-web.ts +++ b/open-sse/executors/adapta-web.ts @@ -33,6 +33,15 @@ interface CachedSession { jwtExpiresAt: number; // unix ms } +const SESSION_CACHE_MAX = 100; + +function evictOldest(cache: Map): void { + if (cache.size >= SESSION_CACHE_MAX) { + const first = cache.keys().next().value; + if (first) cache.delete(first); + } +} + // Keyed by the first 32 chars of the stored __client JWT const sessionCache = new Map(); @@ -44,11 +53,15 @@ function cachedJwt(clientJwt: string): string | null { const entry = sessionCache.get(cacheKey(clientJwt)); if (!entry) return null; // Keep a 30-second buffer before expiry - if (Date.now() >= entry.jwtExpiresAt - 30_000) return null; + if (Date.now() >= entry.jwtExpiresAt - 30_000) { + sessionCache.delete(cacheKey(clientJwt)); + return null; + } return entry.jwt; } function storeSession(clientJwt: string, sessionId: string, jwt: string, expMs: number): void { + evictOldest(sessionCache); sessionCache.set(cacheKey(clientJwt), { sessionId, jwt, jwtExpiresAt: expMs }); } diff --git a/open-sse/executors/antigravity/sseCollect.ts b/open-sse/executors/antigravity/sseCollect.ts index 630b091aab..5b7ef3ea85 100644 --- a/open-sse/executors/antigravity/sseCollect.ts +++ b/open-sse/executors/antigravity/sseCollect.ts @@ -16,6 +16,11 @@ export type AntigravityCollectedStream = { remainingCredits: Array<{ creditType: string; creditAmount: string }> | null; }; +// Both run once per SSE data line / per text part (processAntigravitySSEPayload), +// so the literals are hoisted to module constants. +const TEXTUAL_TOOL_CALL_RE = + /^[\s\S]*?\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/; + export function stripZeroWidth(value: unknown): unknown { if (typeof value === "string") { return stripObfuscationZeroWidth(value); @@ -39,9 +44,7 @@ export function parseAntigravityTextualToolCall( ): { name: string; args: unknown } | null { if (typeof text !== "string") return null; const normalized = stripObfuscationZeroWidth(text); - const match = normalized.match( - /^[\s\S]*?\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/ - ); + const match = normalized.match(TEXTUAL_TOOL_CALL_RE); if (!match) return null; const name = match[1]?.trim(); const rawArgs = match[2]?.trim(); diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 62e3a1da13..4701f175c2 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -538,6 +538,10 @@ export function codexDropNonstandardEvents(): boolean { // every `codex.*` event block from the byte stream before it reaches the client. // Exported for unit testing (#4715). Strips `codex.*` SSE event blocks from a // streaming Response when `codexDropNonstandardEvents()` is on (default, #11014). +// Pre-compiled: the filter's transform() runs on every chunk, so these were +// re-allocated per block/iteration before hoisting. +const CODEX_SSE_EVENT_LINE_RE = /^event:\s*(.+)$/m; +const CODEX_SSE_BLOCK_SEP_RE = /\r?\n\r?\n/; export function filterNonstandardCodexSse(response: Response): Response { const contentType = response.headers.get("content-type") || ""; if (!response.body || !contentType.includes("text/event-stream")) { @@ -547,14 +551,14 @@ export function filterNonstandardCodexSse(response: Response): Response { const encoder = new TextEncoder(); let buffer = ""; const dropBlock = (block: string): boolean => { - const match = /^event:\s*(.+)$/m.exec(block); + const match = CODEX_SSE_EVENT_LINE_RE.exec(block); return !!match && match[1].trim().startsWith("codex."); }; const transform = new TransformStream({ transform(chunk, controller) { buffer += decoder.decode(chunk, { stream: true }); while (true) { - const separator = /\r?\n\r?\n/.exec(buffer); + const separator = CODEX_SSE_BLOCK_SEP_RE.exec(buffer); if (!separator) break; const blockEnd = separator.index + separator[0].length; const block = buffer.slice(0, blockEnd); diff --git a/open-sse/executors/glm.ts b/open-sse/executors/glm.ts index 7f1b850b23..ef9f370669 100644 --- a/open-sse/executors/glm.ts +++ b/open-sse/executors/glm.ts @@ -223,6 +223,8 @@ export function translateSseResponse( suppressThinkClose: boolean = false ): Response { if (!response.body) return response; + // GLM is a high-throughput provider — use a larger stream buffer (64KB) to + // keep provider → client pacing ahead of the model's token emission rate. const transform = createSSETransformStreamWithLogger( FORMATS.CLAUDE, FORMATS.OPENAI, @@ -236,7 +238,10 @@ export function translateSseResponse( null, null, false, - suppressThinkClose + suppressThinkClose, + undefined, + undefined, + 65536 ); const headers = cloneHeaders(response.headers); headers.set("content-type", "text/event-stream"); diff --git a/open-sse/executors/tinycms.ts b/open-sse/executors/tinycms.ts index 68c916e047..c100f76e38 100644 --- a/open-sse/executors/tinycms.ts +++ b/open-sse/executors/tinycms.ts @@ -16,7 +16,9 @@ async function getPublicIp(): Promise { return publicIp; } try { - const res = await fetch("https://api64.ipify.org?format=json"); + const res = await fetch("https://api64.ipify.org?format=json", { + signal: AbortSignal.timeout(5000), + }); const json = (await res.json()) as { ip: string }; publicIp = json.ip; lastIpFetch = now; @@ -35,6 +37,7 @@ async function fetchChallenge(uuid: string): Promise { Accept: "application/json", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", }, + signal: AbortSignal.timeout(10000), }); if (!res.ok) { throw new Error(`Failed to fetch challenge: ${res.status}`); diff --git a/open-sse/executors/zcodeProtocol.ts b/open-sse/executors/zcodeProtocol.ts index 12a5cd1a0e..b787b73d4f 100644 --- a/open-sse/executors/zcodeProtocol.ts +++ b/open-sse/executors/zcodeProtocol.ts @@ -178,7 +178,7 @@ export class ZcodeAppServerClient implements ZcodeClientLike { private readonly startupTimeoutMs: number; private readonly requestTimeoutMs: number; private child?: ChildProcessWithoutNullStreams; - private outputBuffer = Buffer.alloc(0); + private pendingChunks: Buffer[] = []; private handshakeDone = false; private ready = false; private startPromise?: Promise; @@ -220,7 +220,7 @@ export class ZcodeAppServerClient implements ZcodeClientLike { } this.child = child; - this.outputBuffer = Buffer.alloc(0); + this.pendingChunks = []; this.handshakeDone = false; this.ready = false; child.stdin.on("error", () => { @@ -271,18 +271,29 @@ export class ZcodeAppServerClient implements ZcodeClientLike { } } + // Buffer accumulated stdout bytes. Chunks are collected in an array and + // collapsed into one contiguous buffer only when a complete frame (or the + // hello line) might be present — the previous `concat(prev, chunk)` per data + // event re-allocated the whole buffer on every chunk, i.e. O(n²) total. private onStdout(chunk: Buffer): void { - this.outputBuffer = Buffer.concat([this.outputBuffer, chunk]); + this.pendingChunks.push(chunk); + let total = 0; + for (const part of this.pendingChunks) total += part.byteLength; + const buffer = total === chunk.byteLength && this.pendingChunks.length > 0 + ? chunk + : Buffer.concat(this.pendingChunks); + this.pendingChunks = [buffer]; + if (!this.handshakeDone) { - const newline = this.outputBuffer.indexOf(0x0a); + const newline = buffer.indexOf(0x0a); if (newline < 0) { - if (this.outputBuffer.byteLength > 64 * 1024) { + if (buffer.byteLength > 64 * 1024) { this.serverReadyError?.(new Error("ZCode hello line is too large")); } return; } - const line = this.outputBuffer.subarray(0, newline).toString("utf8").trim(); - this.outputBuffer = this.outputBuffer.subarray(newline + 1); + const line = buffer.subarray(0, newline).toString("utf8").trim(); + this.pendingChunks = [buffer.subarray(newline + 1)]; let hello: unknown; try { hello = JSON.parse(line); @@ -307,9 +318,13 @@ export class ZcodeAppServerClient implements ZcodeClientLike { } private consumeFrames(): void { - while (this.outputBuffer.byteLength >= HEADER_SIZE) { - const type = this.outputBuffer.readUInt8(0); - const length = this.outputBuffer.readUInt32BE(9); + // Collapse to one buffer for frame scanning (only happens once per data + // event since onStdout already deduped), then drop consumed frames. + const buffer = this.pendingChunks[0]; + let offset = 0; + while (buffer.byteLength - offset >= HEADER_SIZE) { + const type = buffer.readUInt8(offset); + const length = buffer.readUInt32BE(offset + 9); if (length > MAX_FRAME_BYTES) { const error = new Error("ZCode frame exceeds the configured safety limit"); this.serverReadyError?.(error); @@ -317,9 +332,9 @@ export class ZcodeAppServerClient implements ZcodeClientLike { return; } const frameLength = HEADER_SIZE + length; - if (this.outputBuffer.byteLength < frameLength) return; - const body = this.outputBuffer.subarray(HEADER_SIZE, frameLength); - this.outputBuffer = this.outputBuffer.subarray(frameLength); + if (buffer.byteLength - offset < frameLength) break; + const body = buffer.subarray(offset + HEADER_SIZE, offset + frameLength); + offset += frameLength; if (type !== REGULAR_MESSAGE) continue; try { const header = decodeZcodeValue(body, 0); @@ -331,6 +346,7 @@ export class ZcodeAppServerClient implements ZcodeClientLike { this.rejectPending(normalized); } } + if (offset > 0) this.pendingChunks = [buffer.subarray(offset)]; } private handleMessage(headerValue: unknown, payload: unknown): void { diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index ce2d2af227..66d00d099b 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -1061,6 +1061,7 @@ function convertOpenAIResponseToResponses(openaiResponse: JsonRecord): JsonRecor /** * Sanitize a streaming SSE chunk for passthrough mode. * Lighter than full sanitization — only strips problematic extra fields. + * Fast-path: returns original when no mutations are needed. */ export function sanitizeStreamingChunk(parsed: unknown): unknown { const parsedRecord = toRecord(parsed); @@ -1078,14 +1079,29 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown { if (eventType === "content_block_delta") { const deltaRecord = toRecord(parsedRecord.delta); if (deltaRecord) { + let mutated = false; if (typeof deltaRecord.text === "string") { deltaRecord.text = stripZeroWidthText(deltaRecord.text); + mutated = true; } if (typeof deltaRecord.thinking === "string") { deltaRecord.thinking = stripZeroWidthText(deltaRecord.thinking); + mutated = true; } + return mutated ? parsedRecord : parsed; } - return parsedRecord; + return parsed; + } + + // Fast-path: check if any mutations would actually be needed + // Most passthrough chunks (content deltas) need no sanitization + const needsIdNormalization = parsedRecord.id !== undefined && parsedRecord.id !== null && typeof parsedRecord.id !== "string"; + const hasChoices = Array.isArray(parsedRecord.choices) && parsedRecord.choices.length > 0; + const hasUsage = parsedRecord.usage !== undefined; + const hasSystemFingerprint = parsedRecord.system_fingerprint !== undefined; + if (!needsIdNormalization && !hasChoices && !hasUsage && !hasSystemFingerprint) { + // Nothing to sanitize — forward original + return parsed; } // Build sanitized chunk diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 36f1c8b766..c1747cedc9 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -64,6 +64,16 @@ import { MAX_SHORT_RETRY_HINT_MS, } from "./retryAfterJson.ts"; +// Pre-compiled regex constants for hot-path retry parsing (avoid per-call compilation) +const RETRY_AFTER_RE = /retry\s+after\s+(\d+)\s*s/i; +const PLEASE_RETRY_RE = /please retry in\s+([\d.]+\s*s)/i; +const ISO_RETRY_RE = /\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i; +const RESETS_AFTER_RE = /resets? after (\d+h)?(\d+m)?(\d+s)?/i; +const WILL_RESET_AFTER_RE = /will reset after (\d+h)?(\d+m)?(\d+s)?/i; +const RESETS_IN_RE = /resets? in (\d+h)?(\d+m)?(\d+s)?/i; +const RETRY_IN_SEC_RE = /please retry in (\d+(?:\.\d+)?)\s*s/i; +const COOLDOWN_NUMERIC_RE = /^\d+(\.\d+)?$/; + export type RetryHintProvenance = "header" | "google_rpc_retry_info" | "body"; export function retryHintBypassesMaxCooldownMs( @@ -1371,7 +1381,7 @@ export function parseRetryAfterFromBody(responseBody: unknown): { // OpenAI: "Please retry after 20s" in message const msg = String(error.message || body.message || ""); - const retryMatch = /retry\s+after\s+(\d+)\s*s/i.exec(msg); + const retryMatch = RETRY_AFTER_RE.exec(msg); if (retryMatch) { return { retryAfterMs: Number.parseInt(retryMatch[1], 10) * 1000, @@ -1404,16 +1414,13 @@ export function parseRetryFromErrorText(errorText: unknown): number | null { // Gemini free-tier text fallback (no parseable JSON details present): // "Please retry in 26.660853464s." Short throttle hint — capped independently of // MAX_PROVIDER_COOLDOWN_MS, mirroring the JSON RetryInfo.retryDelay cap (#7940). - const pleaseRetryMs = parseDelayString(/please retry in\s+([\d.]+\s*s)/i.exec(msg)?.[1]); + const pleaseRetryMs = parseDelayString(PLEASE_RETRY_RE.exec(msg)?.[1]); if (pleaseRetryMs !== null && pleaseRetryMs > 0) { return Math.min(pleaseRetryMs, MAX_SHORT_RETRY_HINT_MS); } // Issue #2321: parse embedded absolute ISO retry timestamps. - const isoMatch = - /\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i.exec( - msg - ); + const isoMatch = ISO_RETRY_RE.exec(msg); if (isoMatch) { const parsedTs = Date.parse(isoMatch[1]); if (Number.isFinite(parsedTs)) { @@ -1422,21 +1429,21 @@ export function parseRetryFromErrorText(errorText: unknown): number | null { } } - const match = /resets? after (\d+h)?(\d+m)?(\d+s)?/i.exec(msg); + const match = RESETS_AFTER_RE.exec(msg); if (match?.[1] || match?.[2] || match?.[3]) return computeDurationMs(match); // Variant without "reset after": "will reset after XhYmZs" - const altMatch = /will reset after (\d+h)?(\d+m)?(\d+s)?/i.exec(msg); + const altMatch = WILL_RESET_AFTER_RE.exec(msg); if (altMatch?.[1] || altMatch?.[2] || altMatch?.[3]) return computeDurationMs(altMatch); // Antigravity / Cloud Code phrasing: "Resets in 164h27m24s". - const resetsInMatch = /resets? in (\d+h)?(\d+m)?(\d+s)?/i.exec(msg); + const resetsInMatch = RESETS_IN_RE.exec(msg); if (resetsInMatch?.[1] || resetsInMatch?.[2] || resetsInMatch?.[3]) { return computeDurationMs(resetsInMatch); } // Gemini phrasing: "Please retry in 54.472178091s" (fractional seconds). - const retryInSecMatch = /please retry in (\d+(?:\.\d+)?)\s*s/i.exec(msg); + const retryInSecMatch = RETRY_IN_SEC_RE.exec(msg); if (retryInSecMatch?.[1]) { const sec = Number.parseFloat(retryInSecMatch[1]); if (Number.isFinite(sec) && sec > 0) { @@ -2226,7 +2233,7 @@ export function cooldownUntilMs(value: string | number | Date | null | undefined if (value instanceof Date) return value.getTime(); if (typeof value === "number") return value; const raw = value.trim(); - if (/^\d+(\.\d+)?$/.test(raw)) return Number(raw); + if (COOLDOWN_NUMERIC_RE.test(raw)) return Number(raw); return new Date(raw).getTime(); } diff --git a/open-sse/services/browserPool.ts b/open-sse/services/browserPool.ts index 51a1abb44b..09f1d40866 100644 --- a/open-sse/services/browserPool.ts +++ b/open-sse/services/browserPool.ts @@ -90,13 +90,18 @@ function createBrowserPoolMetrics(): BrowserPoolMetrics { type PoolEngine = "obscura" | "cloakbrowser" | "chromium"; +interface PendingContextEntry { + promise: Promise; + createdAt: number; +} + interface PoolState { browser: Browser | null; /** Engine backing the headless browser, for metrics and stealth detection. */ engine: PoolEngine | null; headedBrowser: Browser | null; contexts: Map; - pendingContexts: Map>; + pendingContexts: Map; launching: Promise | null; headedLaunching: Promise | null; generation: number; @@ -119,7 +124,7 @@ const state: PoolState = { engine: null, headedBrowser: null, contexts: new Map(), - pendingContexts: new Map(), + pendingContexts: new Map; createdAt: number }>(), launching: null, headedLaunching: null, generation: 0, @@ -182,6 +187,15 @@ function evictStaleContexts(): void { pooled.context.close().catch(() => {}); } } + // #12179: also evict pendingContexts entries that never resolved, so a hung + // launch cannot pin the map (and the pool) open forever. + const PENDING_TTL_MS = 5 * 60 * 1000; + for (const [key, pending] of state.pendingContexts) { + if (now - pending.createdAt > PENDING_TTL_MS) { + state.pendingContexts.delete(key); + state.metrics.contextsEvicted++; + } + } if ( state.contexts.size === 0 && state.pendingContexts.size === 0 && @@ -485,7 +499,7 @@ export async function acquireBrowserContext( // Dedup concurrent creations for the same key const pending = state.pendingContexts.get(poolKey); - if (pending) return pending; + if (pending) return pending.promise; const createPromise = (async (): Promise => { const [browser, proxy] = await Promise.all([ @@ -531,7 +545,7 @@ export async function acquireBrowserContext( return pooled; })(); - state.pendingContexts.set(poolKey, createPromise); + state.pendingContexts.set(poolKey, { promise: createPromise, createdAt: Date.now() }); createPromise .then(() => settlePendingContext(poolKey, false)) .catch(() => settlePendingContext(poolKey, true)); diff --git a/open-sse/services/compression/resultMemo.ts b/open-sse/services/compression/resultMemo.ts index b4c64d9112..de198d1043 100644 --- a/open-sse/services/compression/resultMemo.ts +++ b/open-sse/services/compression/resultMemo.ts @@ -149,7 +149,7 @@ export function memoLookup(key: string): CompressionResult | null { memoHits++; recordLookup(true); // Return a clone so downstream mutation cannot corrupt the cached value. - const cloned = JSON.parse(JSON.stringify(hit)) as CompressionResult; + const cloned = structuredClone(hit); if (cloned.stats) { cloned.stats.memoHit = true; } @@ -162,7 +162,7 @@ export function memoStore(key: string, result: CompressionResult): CompressionRe // Returns the stored clone so callers that need a fresh instance (the common // `memoStore(key, result); return memoLookup(key)!` idiom) can avoid a redundant // second multi-MB deep clone of the body on the way out. - const stored = JSON.parse(JSON.stringify(result)) as CompressionResult; + const stored = structuredClone(result); boundedSet(key, stored); return stored; } diff --git a/open-sse/services/gigachatAuth.ts b/open-sse/services/gigachatAuth.ts index 1696acc0e7..8b79d9cf63 100644 --- a/open-sse/services/gigachatAuth.ts +++ b/open-sse/services/gigachatAuth.ts @@ -15,6 +15,22 @@ type GigachatTokenOptions = { const DEFAULT_GIGACHAT_AUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"; const DEFAULT_GIGACHAT_SCOPE = "GIGACHAT_API_PERS"; const CACHE_SKEW_MS = 60_000; +const TOKEN_CACHE_MAX = 100; +const INFLIGHT_MAX = 50; + +function evictOldest(cache: Map): void { + if (cache.size >= TOKEN_CACHE_MAX) { + const first = cache.keys().next().value; + if (first) cache.delete(first); + } +} + +function evictOldestInflight(cache: Map>): void { + if (cache.size >= INFLIGHT_MAX) { + const first = cache.keys().next().value; + if (first) cache.delete(first); + } +} const tokenCache = new Map(); const inflightRequests = new Map>(); @@ -23,10 +39,12 @@ function getCacheKey(credentials: string, authUrl: string, scope: string) { return `${authUrl}::${scope}::${credentials}`; } -function isFreshToken(token: GigachatTokenResult | undefined) { +function isFreshToken(token: GigachatTokenResult | undefined, key?: string) { if (!token?.accessToken || !token?.expiresAt) return false; const expiresAtMs = new Date(token.expiresAt).getTime(); - return Number.isFinite(expiresAtMs) && expiresAtMs - Date.now() > CACHE_SKEW_MS; + const fresh = Number.isFinite(expiresAtMs) && expiresAtMs - Date.now() > CACHE_SKEW_MS; + if (!fresh && key) tokenCache.delete(key); + return fresh; } function normalizeExpiry(rawExpiry: unknown) { @@ -59,7 +77,7 @@ export async function getGigachatAccessToken( const cacheKey = getCacheKey(credentials, authUrl, scope); const cached = tokenCache.get(cacheKey); - if (isFreshToken(cached)) { + if (isFreshToken(cached, cacheKey)) { return cached; } @@ -100,10 +118,12 @@ export async function getGigachatAccessToken( accessToken, expiresAt: normalizeExpiry(data.exp ?? data.expires_at), }; + evictOldest(tokenCache); tokenCache.set(cacheKey, token); return token; })(); + evictOldestInflight(inflightRequests); inflightRequests.set(cacheKey, requestPromise); try { return await requestPromise; diff --git a/open-sse/services/responsesInputSanitizer.ts b/open-sse/services/responsesInputSanitizer.ts index 94cd99f934..48f1eed051 100644 --- a/open-sse/services/responsesInputSanitizer.ts +++ b/open-sse/services/responsesInputSanitizer.ts @@ -11,6 +11,10 @@ const SERVER_ITEM_ID_PREFIX_BY_TYPE: Record = { reasoning: "rs_", }; const SERVER_ITEM_ID_PATTERN = /^(fc|msg|rs|resp)_/; +// Validated per input item of type function_call / function_call_output (the agentic +// Responses path), so kept as a module constant instead of an inline literal. +const FUNCTION_NAME_VALID_RE = /^[a-zA-Z0-9_-]{1,128}$/; +const FUNCTION_NAME_SANITIZE_RE = /[^a-zA-Z0-9_-]/g; function toRecord(value: unknown): JsonRecord | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; @@ -38,7 +42,7 @@ export function isInternalAssistantMessage(record: JsonRecord): boolean { // Sanitize after cloning so upstream never sees an invalid name. function sanitizeFunctionName(name: string): string { // Replace any character not in [a-zA-Z0-9_-] with underscore, then truncate. - return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 128); + return name.replace(FUNCTION_NAME_SANITIZE_RE, "_").slice(0, 128); } function sanitizeInputItemId(record: JsonRecord): JsonRecord { @@ -149,7 +153,7 @@ function sanitizeInputItem(item: unknown): unknown { if ( (next.type === "function_call" || next.type === "function_call_output") && typeof next.name === "string" && - !/^[a-zA-Z0-9_-]{1,128}$/.test(next.name) + !FUNCTION_NAME_VALID_RE.test(next.name) ) { next = { ...next, name: sanitizeFunctionName(next.name) }; } diff --git a/open-sse/utils/composerToolCalls.ts b/open-sse/utils/composerToolCalls.ts index 916903a3ca..40d2c306af 100644 --- a/open-sse/utils/composerToolCalls.ts +++ b/open-sse/utils/composerToolCalls.ts @@ -48,6 +48,18 @@ const INNER_RE = new RegExp( // Match an arg separator. const ARG_SEP_RE = new RegExp(`<${FW}tool${SEP}sep${FW}>`, "gi"); +// Opening-only marker, matched on every streamed delta in the holdback path; +// kept as a module constant so it is compiled once instead of per call. +const OPEN_ONLY_RE = new RegExp(`<${FW}tool${SEP}calls${SEP}begin${FW}>`, "i"); + +// Parse helpers below run once per tool-call block / per argument value during +// streaming, so their literals are hoisted too. +const TRIM_EDGES_RE = /^\s+|\s+$/g; +const FIRST_SPACE_RE = /\s/; +const TRAILING_NEWLINES_RE = /\n+$/; +const INTEGER_RE = /^-?\d+$/; +const DECIMAL_RE = /^-?\d*\.\d+$/; + // Heuristic: any partial opening marker (start of `<|tool` ... without the // final `>`). Used by the streaming parser to know it must hold back text. const PARTIAL_OPEN_MARKER_RE = new RegExp( @@ -115,7 +127,7 @@ function generateToolCallId(index: number): string { function parseInnerCall(body: string): { name: string; arguments: string } | null { // Body starts with the tool name on (typically) its own line, optionally // surrounded by whitespace, then the first `<|tool▁sep|>`. - const trimmed = body.replace(/^\s+|\s+$/g, ""); + const trimmed = body.replace(TRIM_EDGES_RE, ""); // Split by argument separator first to isolate name + arg blocks. const segments = trimmed.split(ARG_SEP_RE); // First segment is the tool name (and any preamble whitespace). @@ -137,7 +149,7 @@ function parseInnerCall(body: string): { name: string; arguments: string } | nul let argName: string; let argValue: string; if (idxNl < 0) { - const idxSp = seg.search(/\s/); + const idxSp = seg.search(FIRST_SPACE_RE); if (idxSp < 0) { argName = seg.trim(); argValue = ""; @@ -155,7 +167,7 @@ function parseInnerCall(body: string): { name: string; arguments: string } | nul if (!argName) continue; // Strip the trailing newline before the next separator (the separator // marker itself was already consumed by the split). - argValue = argValue.replace(/\n+$/, ""); + argValue = argValue.replace(TRAILING_NEWLINES_RE, ""); // Attempt JSON parse so structured args (objects/arrays/numbers/bools) // come through as native JSON values rather than quoted strings. args[argName] = coerceArgValue(argValue); @@ -179,11 +191,11 @@ function coerceArgValue(raw: string): unknown { if (stripped === "true") return true; if (stripped === "false") return false; if (stripped === "null") return null; - if (/^-?\d+$/.test(stripped)) { + if (INTEGER_RE.test(stripped)) { const n = Number(stripped); if (Number.isSafeInteger(n)) return n; } - if (/^-?\d*\.\d+$/.test(stripped)) { + if (DECIMAL_RE.test(stripped)) { const n = Number(stripped); if (Number.isFinite(n)) return n; } @@ -295,8 +307,7 @@ export function feedStreamingChunk(state: StreamingState, accumulated: string): // 2. Look for an opening-only marker. If found, everything before it is // safe; everything after must be held until we see the closing marker. - const openOnlyRe = new RegExp(`<${FW}tool${SEP}calls${SEP}begin${FW}>`, "i"); - const openMatch = accumulated.match(openOnlyRe); + const openMatch = accumulated.match(OPEN_ONLY_RE); if (openMatch && openMatch.index !== undefined) { const safe = accumulated.slice(0, openMatch.index); const safeDelta = safe.length > state.emitted ? safe.slice(state.emitted) : ""; diff --git a/open-sse/utils/reasoningFields.ts b/open-sse/utils/reasoningFields.ts index 21fc22cab1..75b7cbe537 100644 --- a/open-sse/utils/reasoningFields.ts +++ b/open-sse/utils/reasoningFields.ts @@ -21,45 +21,61 @@ export function extractReasoningDetailsText(value: unknown): string { .join(""); } -export function getReadableReasoningValue(value: unknown): string { +/** + * Consolidated reasoning field extraction - single pass returns all categories + * to avoid 3-5 separate object traversals per chunk. + */ +export interface ReasoningFields { + readable: string; + unsupported: string; + any: string; + hasUnsupportedSignal: boolean; + hasAnySignal: boolean; +} + +export function extractReasoningFields(value: unknown): ReasoningFields { const record = asReasoningRecord(value); - return nonEmptyString(record.reasoning_content) || nonEmptyString(record.reasoning); + + const readable = nonEmptyString(record.reasoning_content) || nonEmptyString(record.reasoning); + const reasoningText = nonEmptyString(record.reasoning_text); + const thinking = nonEmptyString(record.thinking); + const thought = nonEmptyString(record.thought); + const details = extractReasoningDetailsText(record); + + const unsupported = reasoningText || thinking || thought || details; + const any = readable || unsupported; + + const hasUnsupportedSignal = !!( + !readable && + (reasoningText || + thinking || + thought || + (Array.isArray(record.reasoning_details) && record.reasoning_details.length > 0)) + ); + const hasAnySignal = !!any; + + return { readable, unsupported, any, hasUnsupportedSignal, hasAnySignal }; +} + +/** Back-compat wrappers for existing callers - delegate to consolidated extractor. */ +export function getReadableReasoningValue(value: unknown): string { + return extractReasoningFields(value).readable; } export function getUnsupportedReasoningValue(value: unknown): string { - const record = asReasoningRecord(value); - return ( - nonEmptyString(record.reasoning_text) || - nonEmptyString(record.thinking) || - nonEmptyString(record.thought) || - extractReasoningDetailsText(record) - ); + return extractReasoningFields(value).unsupported; } export function getAnyReasoningValue(value: unknown): string { - return getReadableReasoningValue(value) || getUnsupportedReasoningValue(value); + return extractReasoningFields(value).any; } export function hasUnsupportedReasoningSignal(value: unknown): boolean { - const record = asReasoningRecord(value); - return Boolean( - !getReadableReasoningValue(record) && - (nonEmptyString(record.reasoning_text) || - nonEmptyString(record.thinking) || - nonEmptyString(record.thought) || - (Array.isArray(record.reasoning_details) && record.reasoning_details.length > 0)) - ); + return extractReasoningFields(value).hasUnsupportedSignal; } export function hasAnyReasoningSignal(value: unknown): boolean { - const record = asReasoningRecord(value); - return Boolean( - getReadableReasoningValue(record) || - nonEmptyString(record.reasoning_text) || - nonEmptyString(record.thinking) || - nonEmptyString(record.thought) || - (Array.isArray(record.reasoning_details) && record.reasoning_details.length > 0) - ); + return extractReasoningFields(value).hasAnySignal; } const STRIPPABLE_REASONING_FIELDS = [ diff --git a/open-sse/utils/responsesStreamHelpers.ts b/open-sse/utils/responsesStreamHelpers.ts index a2cba80fc1..f77b2a0c53 100644 --- a/open-sse/utils/responsesStreamHelpers.ts +++ b/open-sse/utils/responsesStreamHelpers.ts @@ -98,25 +98,29 @@ function buildResponsesOutputItemKey(item: unknown): string | null { return `${type}:${id}:${callId}:${outputIndex}:${name}`; } +// Module-level Set reused across calls to avoid allocation per event +const _seenResponsesKeys = new Set(); + export function pushUniqueResponsesOutputItems(target: unknown[], items: readonly unknown[]) { - const seen = new Set(); + // Clear the reused Set instead of allocating new one + _seenResponsesKeys.clear(); for (const existingItem of target) { const key = buildResponsesOutputItemKey(existingItem); if (key) { - seen.add(key); + _seenResponsesKeys.add(key); } } for (const item of items) { const key = buildResponsesOutputItemKey(item); - if (key && seen.has(key)) { + if (key && _seenResponsesKeys.has(key)) { continue; } target.push(item); if (key) { - seen.add(key); + _seenResponsesKeys.add(key); } } } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index dd37eda217..d33cc8a526 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -178,6 +178,8 @@ type StreamOptions = { * codex-compatible `namespace` + `name` fields. */ requestToolIdentityMap?: Map | null; + /** High water mark for the TransformStream internal buffer (default: 16384) */ + highWaterMark?: number; }; type TranslateState = ReturnType & { @@ -1173,6 +1175,8 @@ export function createSSEStream(options: StreamOptions = {}) { } }; + const highWaterMark = options.highWaterMark ?? 16384; + return new TransformStream( { start(controller) { @@ -2992,8 +2996,8 @@ export function createSSEStream(options: StreamOptions = {}) { clearIdleTimer(); }, }, - { highWaterMark: 16384 }, - { highWaterMark: 16384 } + { highWaterMark }, + { highWaterMark } ); } @@ -3015,7 +3019,8 @@ export function createSSETransformStreamWithLogger( copilotCompatibleReasoning = false, suppressThinkClose = false, customToolNames: ReadonlySet = new Set(), - requestToolIdentityMap: Map | null = null + requestToolIdentityMap: Map | null = null, + highWaterMark?: number ) { return createSSEStream({ mode: STREAM_MODE.TRANSLATE, @@ -3034,6 +3039,7 @@ export function createSSETransformStreamWithLogger( suppressThinkClose, customToolNames, requestToolIdentityMap, + highWaterMark, }); } @@ -3048,7 +3054,8 @@ export function createPassthroughStreamWithLogger( apiKeyInfo: unknown = null, onFailure: ((payload: StreamFailurePayload) => boolean | void | Promise) | null = null, clientResponseFormat: string | null = null, - requestToolIdentityMap: Map | null = null + requestToolIdentityMap: Map | null = null, + highWaterMark?: number ) { return createSSEStream({ mode: STREAM_MODE.PASSTHROUGH, @@ -3063,6 +3070,7 @@ export function createPassthroughStreamWithLogger( onFailure, clientResponseFormat, requestToolIdentityMap, + highWaterMark, }); } diff --git a/open-sse/utils/streamHandler.ts b/open-sse/utils/streamHandler.ts index dbcc439eef..7776f2e5e9 100644 --- a/open-sse/utils/streamHandler.ts +++ b/open-sse/utils/streamHandler.ts @@ -629,7 +629,11 @@ function resolveSilentCloseOutcome(input: { return null; } -export function createDisconnectAwareStream(transformStream, streamController) { +export function createDisconnectAwareStream( + transformStream, + streamController, + options: { highWaterMark?: number } = {} +) { const reader = transformStream.readable.getReader(); const writer = transformStream.writable.getWriter(); const terminalDecoder = new TextDecoder(); @@ -697,6 +701,8 @@ export function createDisconnectAwareStream(transformStream, streamController) { } }; + const highWaterMark = options.highWaterMark ?? 16384; + return new ReadableStream( { async pull(controller) { @@ -818,7 +824,7 @@ export function createDisconnectAwareStream(transformStream, streamController) { await Promise.allSettled([reader.cancel(reason), writer.abort(reason)]); }, }, - { highWaterMark: 16384 } + { highWaterMark } ); } @@ -845,7 +851,7 @@ export function pipeWithDisconnect( providerResponse: Response, transformStream: TransformStream, streamController: StreamController, - opts: { stallTimeoutMs?: number } = {} + opts: { stallTimeoutMs?: number; highWaterMark?: number } = {} ) { const stallTimeoutMs = opts.stallTimeoutMs ?? DEFAULT_STREAM_STALL_TIMEOUT_MS; @@ -854,7 +860,8 @@ export function pipeWithDisconnect( const transformedBody = providerResponse.body.pipeThrough(transformStream); return createDisconnectAwareStream( { readable: transformedBody, writable: createNoopAbortWritable() }, - streamController + streamController, + { highWaterMark: opts.highWaterMark } ); } @@ -956,6 +963,7 @@ export function pipeWithDisconnect( .pipeThrough(transformStream); return createDisconnectAwareStream( { readable: transformedBody, writable: createNoopAbortWritable() }, - wrappedController + wrappedController, + { highWaterMark: opts.highWaterMark } ); } diff --git a/open-sse/utils/streamHelpers.ts b/open-sse/utils/streamHelpers.ts index db8c656d1d..39aafbacf9 100644 --- a/open-sse/utils/streamHelpers.ts +++ b/open-sse/utils/streamHelpers.ts @@ -70,6 +70,13 @@ function isRecord(value: unknown): value is Record { const ANSI_ESCAPE_RE = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[A-Z\[\]\\^_`])|[\x00-\x08\x0b\x0c\x0e-\x1f]/g; +// Pre-compiled regex constants for hot-path SSE processing (avoid per-call compilation) +const CR_STRIP_RE = /\r$/; +const SSE_FIELD_RE = /^(?:event:|id:|retry:|:)/i; +const SSE_EVENT_RE = /^event:\s*(.+)$/i; +const SSE_ID_RETRY_RE = /^(?::|id:|retry:)/i; +const SSE_EVENT_ONLY_RE = /^event:/i; + /** * Strip ANSI/VT100 escape sequences (and stray C0 controls) from a string. * Non-string inputs (null/undefined) are returned unchanged. Preserves \t \n \r. @@ -125,7 +132,7 @@ export function parseSSELine(line: string): SSEJsonPayload | null { } function extractSseDataLine(line: string): string | null { - const trimmed = stripAnsiCodes(line.trimStart().replace(/\r$/, "")); + const trimmed = stripAnsiCodes(line.trimStart().replace(CR_STRIP_RE, "")); if (!trimmed.startsWith("data:")) return null; return trimmed.slice(5).trimStart(); } @@ -192,12 +199,12 @@ export function createSSEDataLineNormalizer(): SSEDataLineNormalizer { normalize(lines: string[]) { const output: string[] = []; for (const line of lines) { - const normalizedLine = line.replace(/\r$/, ""); + const normalizedLine = line.replace(CR_STRIP_RE, ""); const trimmed = normalizedLine.trim(); if ( trimmed && - /^(?:event:|id:|retry:|:)/i.test(trimmed) && + SSE_FIELD_RE.test(trimmed) && hasSelfDescribingPendingDataPayload() ) { flush(output); @@ -235,7 +242,7 @@ export function createSSEEventPrefixBuffer(options?: { forwardEvent?: boolean }) }, eventType() { for (let i = lines.length - 1; i >= 0; i--) { - const match = lines[i].trim().match(/^event:\s*(.+)$/i); + const match = lines[i].trim().match(SSE_EVENT_RE); if (match) return match[1].trim(); } return ""; @@ -251,10 +258,10 @@ export function createSSEEventPrefixBuffer(options?: { forwardEvent?: boolean }) // `id:`/`retry:` and bare `:` comment lines are not part of any of the // OpenAI Chat-Completions, OpenAI Responses, or Claude Messages SSE // protocols — never buffer (and thus never re-forward) them (#10017). - if (/^(?::|id:|retry:)/i.test(trimmed)) return; + if (SSE_ID_RETRY_RE.test(trimmed)) return; // `event:` framing is only forwarded for protocols that define it; drop it // for plain OpenAI Chat-Completions-format clients. - if (/^event:/i.test(trimmed) && !forwardEvent) return; + if (SSE_EVENT_ONLY_RE.test(trimmed) && !forwardEvent) return; lines.push(line); emitted = false; }, diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index 1b605acc0c..25a1a20d8b 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -680,10 +680,29 @@ export function isEmptyUsage(usage: unknown): boolean { /** * Extract usage from supported formats (Claude, OpenAI, Gemini, Responses API) + * Fast-path: return early for chunks without any usage-related fields. + * Most streaming chunks (content deltas) have no usage — avoids property checks. */ export function extractUsage(chunk: UsagePayloadLike | null | undefined) { if (!chunk || typeof chunk !== "object") return null; + // Fast-path: check for any usage-like fields before doing full extraction + // Most chunks are content deltas with no usage — return null immediately. + const c = chunk as Record; + const response = c.response as Record | undefined; + const message = c.message as Record | undefined; + if ( + !c.type && + c.usage === undefined && + c.usageMetadata === undefined && + response?.usage === undefined && + response?.usageMetadata === undefined && + message?.usage === undefined && + c.done !== true + ) { + return null; + } + // Claude/Antigravity streaming: message_start event carries INPUT tokens // FIX #74: This event was not handled — input_tokens were being dropped // Structure: { type: "message_start", message: { usage: { input_tokens: N, output_tokens: 0 } } } diff --git a/package.json b/package.json index b3b33ae2f3..bbfee22bc7 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "bench:compression": "bun scripts/compression/benchmark.ts", "bench:heap-body": "node --expose-gc --import tsx/esm scripts/perf/request-body-heap.ts", "bench:routing-events": "node --import tsx/esm scripts/perf/routing-events-bench.ts", + "bench:highwatermark": "node --import tsx/esm scripts/perf/benchmark-highwatermark.ts", "eval:compression": "node --import tsx scripts/compression-eval/index.ts", "eval:router": "node --import tsx scripts/router-eval/index.ts", "eval:router:compare": "node --import tsx scripts/router-eval/compare.ts", From 7d0b264eda6a480ee9b3c355802f094adc4050ce Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 3 Sep 2026 08:32:22 -0300 Subject: [PATCH 17/47] chore(electron): drop openAsHidden/wasOpenedAsHidden, removed in Electron 44 (#12554) CI verde: 18 checks passando (4 shards de unit, Vitest, CodeQL, Fast Quality Gates, No new ESLint warnings, semgrep). Local: 8/8 no teste atualizado e 165/165 nos 16 arquivos tests/unit/electron-*.test.ts. --- changelog.d/maintenance/12554-electron-44.md | 1 + electron/lib/windowLifecycle.js | 12 +++++------ electron/main.js | 2 -- tests/unit/electron-lazy-window.test.ts | 21 ++++++++++++++++++-- 4 files changed, 26 insertions(+), 10 deletions(-) create mode 100644 changelog.d/maintenance/12554-electron-44.md diff --git a/changelog.d/maintenance/12554-electron-44.md b/changelog.d/maintenance/12554-electron-44.md new file mode 100644 index 0000000000..41b5e50f53 --- /dev/null +++ b/changelog.d/maintenance/12554-electron-44.md @@ -0,0 +1 @@ +- **chore(electron):** upgrade the desktop app to Electron 44 (Chromium 152, Node 24.18.1) ([#12217](https://github.com/diegosouzapw/OmniRoute/pull/12217)). **Requires macOS 13 (Ventura) or later** — Chromium dropped macOS 12 (Monterey), so Monterey users must stay on an earlier OmniRoute desktop build. Windows and Linux are unaffected; the app already shipped only x64/arm64, so Electron 44 dropping 32-bit builds changes nothing. Removes the `openAsHidden`/`wasOpenedAsHidden` login-item fields deleted in Electron 44 — hidden autostart continues to work through the `--hidden` argument registered with the login item ([#12554](https://github.com/diegosouzapw/OmniRoute/pull/12554)) diff --git a/electron/lib/windowLifecycle.js b/electron/lib/windowLifecycle.js index 8a75a4a18d..cea524ab37 100644 --- a/electron/lib/windowLifecycle.js +++ b/electron/lib/windowLifecycle.js @@ -1,11 +1,11 @@ /** Pure helpers for deciding and driving the Electron dashboard window lifecycle. */ -function shouldStartHidden({ argv = [], loginItemSettings = {} } = {}) { - return ( - argv.includes("--hidden") || - argv.includes("--minimized") || - loginItemSettings.wasOpenedAsHidden === true - ); +// Electron 44 removed `openAsHidden`/`wasOpenedAsHidden` from +// `app.set/getLoginItemSettings()` (they only ever worked on macOS 12 and below, which +// Electron 44 no longer supports). The hidden-autostart contract is now carried solely by +// the `--hidden` argument registered with the login item. +function shouldStartHidden({ argv = [] } = {}) { + return argv.includes("--hidden") || argv.includes("--minimized"); } function showOrCreateWindow({ appReady, getWindow, createWindow }) { diff --git a/electron/main.js b/electron/main.js index 19f232226b..2010fe5683 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1113,7 +1113,6 @@ function setupIpcHandlers() { try { app.setLoginItemSettings({ openAtLogin: true, - openAsHidden: true, args: ["--hidden"], }); return true; @@ -1153,7 +1152,6 @@ app.whenReady().then(async () => { !isHeadless && shouldStartHidden({ argv: process.argv, - loginItemSettings: app.getLoginItemSettings(), }); keepAliveWithoutWindows = startHidden; diff --git a/tests/unit/electron-lazy-window.test.ts b/tests/unit/electron-lazy-window.test.ts index f9d3f97677..08515bd570 100644 --- a/tests/unit/electron-lazy-window.test.ts +++ b/tests/unit/electron-lazy-window.test.ts @@ -8,14 +8,31 @@ const require = createRequire(import.meta.url); const { shouldStartHidden, showOrCreateWindow } = require("../../electron/lib/windowLifecycle"); describe("Electron hidden-start window lifecycle", () => { - it("detects explicit hidden flags and OS login-item hidden launches", () => { + it("detects explicit hidden flags", () => { assert.equal(shouldStartHidden({ argv: ["electron", "--hidden"] }), true); assert.equal(shouldStartHidden({ argv: ["electron", "--minimized"] }), true); + assert.equal(shouldStartHidden({ argv: ["electron"] }), false); + assert.equal(shouldStartHidden(), false); + }); + + // Electron 44 removed `wasOpenedAsHidden` from `app.getLoginItemSettings()`, so a hidden + // autostart is signalled ONLY by the `--hidden` argument the login item registers. Guards + // against re-introducing a dependency on the removed field. + it("ignores login-item settings entirely", () => { assert.equal( shouldStartHidden({ argv: ["electron"], loginItemSettings: { wasOpenedAsHidden: true } }), + false + ); + assert.equal( + shouldStartHidden({ argv: ["electron", "--hidden"], loginItemSettings: {} }), true ); - assert.equal(shouldStartHidden({ argv: ["electron"], loginItemSettings: {} }), false); + }); + + it("keeps the --hidden argument registered with the login item", () => { + const mainJs = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8"); + assert.match(mainJs, /openAtLogin: true,\s*\n\s*args: \["--hidden"\],/); + assert.doesNotMatch(mainJs, /openAsHidden/); }); it("creates the dashboard only when an explicit open action has no live window", () => { From c41d8755db0c964471c228d7d641ba563fbb6211 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 3 Sep 2026 08:39:46 -0300 Subject: [PATCH 18/47] fix(ci): document eloqnt MIT exceptions and isolate A2A vitest (#12595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado numa worktree sobre o tip de `release/v3.8.51`. Verifiquei as premissas em vez de aceitar a justificativa: **Exceções de licença** — todas as afirmações batem. Os três pacotes estão instalados exatamente nas versões travadas citadas (`@eloqnt/config@0.0.2`, `@eloqnt/format-json@0.0.3`, `@eloqnt/format-po@0.0.3`), os três **omitem `package.json#license` e não têm arquivo LICENSE**, e `npm ls` confirma que são transitivos de `next-intl@4.14.1` (MIT). No registry, o SPDX das três é MIT e o `@eloqnt/config@0.1.0` existe, como o texto diz. Uso de `exceptions` (com `risk`/`reviewAt`) em vez de `allowed: UNKNOWN` é o mecanismo certo. `check:licenses` verde: **0 violações de política**, 948 permitidos, as três novas entradas aparecendo como exceções sinalizadas não bloqueantes ao lado das duas já existentes. Teste-guarda `tests/unit/build/check-licenses.test.ts`: **36/36**. **Vitest do A2A lifecycle** — as duas seams usadas já existiam antes deste PR: `constructor(ttlMinutes = 5, persistence: A2APersistence = defaultPersistence)` e o 4º parâmetro opcional `deps?: MemoryHitsDeps` de `executeA2ATaskWithState`. O padrão é idêntico ao que `tests/unit/a2a-task-persistence.test.ts` já fazia. Nenhuma asserção foi removida ou enfraquecida — e como o comportamento de persistência tem suíte dedicada, injetar no-ops aqui tira acoplamento incidental, não cobertura. Ganho medido nos dois lados: corpo dos testes **784ms no tip → 233ms com o PR**, sem nenhuma linha `[DB] SQLite database ready`. Registro honesto: **no tip o arquivo passa localmente** (4/4) — o red era do CI, sob o thread pool com as 167 migrações; localmente dá para comprovar o mecanismo e a aceleração, não a falha em si. Suíte vitest completa: **51/51 arquivos, 465/465 testes**. --- .../12581-basereds-licenses-a2a-lifecycle.md | 1 + config/quality/.license-allowlist.json | 18 +++++++++++++ .../mcp-server/__tests__/a2aLifecycle.test.ts | 25 +++++++++++++++---- tests/unit/build/check-licenses.test.ts | 16 ++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixes/12581-basereds-licenses-a2a-lifecycle.md diff --git a/changelog.d/fixes/12581-basereds-licenses-a2a-lifecycle.md b/changelog.d/fixes/12581-basereds-licenses-a2a-lifecycle.md new file mode 100644 index 0000000000..8dbb345f96 --- /dev/null +++ b/changelog.d/fixes/12581-basereds-licenses-a2a-lifecycle.md @@ -0,0 +1 @@ +- **fix(ci):** document MIT exceptions for `@eloqnt/{config,format-json,format-po}` (next-intl transitive; locked tarballs omit `license`) and keep the A2A lifecycle vitest off the real SQLite persistence seam ([#12581](https://github.com/diegosouzapw/OmniRoute/issues/12581)) diff --git a/config/quality/.license-allowlist.json b/config/quality/.license-allowlist.json index f2cca50374..c74ee066e4 100644 --- a/config/quality/.license-allowlist.json +++ b/config/quality/.license-allowlist.json @@ -74,6 +74,24 @@ "justification": "CC-BY-4.0 applies to the caniuse browser-support data (a dataset, not code). The Creative Commons Attribution license requires attribution when distributing — OmniRoute does not distribute caniuse-lite data directly to end users; it is consumed by browserslist/PostCSS at build time to generate CSS compatibility info. This is a widely accepted pattern in the Node.js ecosystem (caniuse-lite is in millions of projects). Attribution is satisfied by keeping the package in node_modules with its original license file.", "risk": "low", "reviewAt": "v4.0.0" + }, + "@eloqnt/config": { + "license": "MIT", + "justification": "Transitive of next-intl (MIT). npm registry SPDX for the @eloqnt scope is MIT; @eloqnt/config@0.1.0 republished with license: MIT. The locked 0.0.2 tarball (next-intl's ^0.0.2 range, which is 0.0.x only) omits both package.json#license and a LICENSE file, so license-checker reports UNKNOWN. Same author (Jan Amann / amannn). OmniRoute does not modify the package. Re-review when next-intl bumps the range to a release that ships the license field.", + "risk": "low", + "reviewAt": "v4.0.0" + }, + "@eloqnt/format-json": { + "license": "MIT", + "justification": "Same as @eloqnt/config: next-intl transitive, registry SPDX MIT, locked 0.0.3 tarball omits license field and LICENSE file so the checker reports UNKNOWN. Re-review with the next-intl range bump.", + "risk": "low", + "reviewAt": "v4.0.0" + }, + "@eloqnt/format-po": { + "license": "MIT", + "justification": "Same as @eloqnt/config: next-intl transitive, registry SPDX MIT, locked 0.0.3 tarball omits license field and LICENSE file so the checker reports UNKNOWN. Re-review with the next-intl range bump.", + "risk": "low", + "reviewAt": "v4.0.0" } } } diff --git a/open-sse/mcp-server/__tests__/a2aLifecycle.test.ts b/open-sse/mcp-server/__tests__/a2aLifecycle.test.ts index 470dc1f2bd..b660006a0f 100644 --- a/open-sse/mcp-server/__tests__/a2aLifecycle.test.ts +++ b/open-sse/mcp-server/__tests__/a2aLifecycle.test.ts @@ -1,11 +1,21 @@ import { afterEach, describe, expect, it } from "vitest"; -import { A2ATaskManager } from "../../../src/lib/a2a/taskManager.ts"; +import { A2ATaskManager, type A2APersistence } from "../../../src/lib/a2a/taskManager.ts"; import { executeA2ATaskWithState } from "../../../src/lib/a2a/taskExecution.ts"; const managers: A2ATaskManager[] = []; +// Default persistence opens SQLite (167 migrations) inside the vitest thread pool. +// Tests inject a no-op so they never touch the DB (same seam as a2a-task-persistence.test.ts). +function noopPersistence(): A2APersistence { + return { + upsert: (() => {}) as A2APersistence["upsert"], + appendEvent: (() => {}) as A2APersistence["appendEvent"], + purge: ((): number => 0) as A2APersistence["purge"], + }; +} + function createManager(ttlMinutes = 5) { - const manager = new A2ATaskManager(ttlMinutes); + const manager = new A2ATaskManager(ttlMinutes, noopPersistence()); managers.push(manager); return manager; } @@ -44,9 +54,14 @@ describe("A2A task lifecycle regressions", () => { tm.updateTask(task.id, "working"); await expect( - executeA2ATaskWithState(tm, task, async () => { - throw new Error("upstream failure"); - }) + executeA2ATaskWithState( + tm, + task, + async () => { + throw new Error("upstream failure"); + }, + { search: async () => [], appendEvent: () => {} } + ) ).rejects.toThrow("upstream failure"); const loaded = tm.getTask(task.id); diff --git a/tests/unit/build/check-licenses.test.ts b/tests/unit/build/check-licenses.test.ts index 746f9cc9dc..8fb104ef9a 100644 --- a/tests/unit/build/check-licenses.test.ts +++ b/tests/unit/build/check-licenses.test.ts @@ -330,3 +330,19 @@ test("integration: classifyLicense denies AGPL-3.0 against real allowlist", () = const result = classifyLicense("hypothetical-agpl@1.0.0", "AGPL-3.0", allowlist); assert.equal(result.status, "denied"); }); + +test("integration: @eloqnt/* UNKNOWN licenses are documented exceptions (next-intl transitive)", () => { + const allowlist = loadAllowlist(); + for (const pkg of [ + "@eloqnt/config@0.0.2", + "@eloqnt/format-json@0.0.3", + "@eloqnt/format-po@0.0.3", + ]) { + const result = classifyLicense(pkg, "UNKNOWN", allowlist); + assert.equal( + result.status, + "exception", + `${pkg} ships no license field; must be a documented exception, not allowed/denied` + ); + } +}); From e1cf5423785820d073e42b4f1536c48083a184b2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 3 Sep 2026 09:07:15 -0300 Subject: [PATCH 19/47] chore(deps): bump fast-uri to 3.1.7 in the electron lockfile (#12601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependabot alerts #196–#199 — four HIGH advisories on fast-uri (GHSA-jqff-g426-hqxp, GHSA-fph4-wmhf-6fwf, GHSA-f65p-4m7j-42xc, GHSA-5jgf-p345-68v8), all patched in 3.1.6. The root package-lock.json was already on a patched fast-uri (3.1.7) — those alerts close on their own with the next scan. `electron/package-lock.json` is a second lockfile and was still pinning 3.1.5, which is what these four alerts are actually reporting. Transitive, one copy, pulled by ajv (`^3.0.1`), so a package-lock-only update lifts it without touching any manifest. The diff is three lines: version, resolved and integrity for that single entry. check:lockfile and check:tracked-artifacts pass. Not fixed here: extract-zip (#191, HIGH, <= 2.0.1) has no published patch. It comes in through @openai/codex-security and is dev-scope; it needs either an upstream release or a decision to drop/replace the dependency, neither of which belongs in a lockfile bump. --- electron/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/electron/package-lock.json b/electron/package-lock.json index 7dbc37a13c..8eb9f2634a 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1575,9 +1575,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { From 3f3d27e264ecdcd8479e6f40dd598ddea88ec7e3 Mon Sep 17 00:00:00 2001 From: Giorgos Giakoumettis Date: Thu, 3 Sep 2026 15:10:45 +0300 Subject: [PATCH 20/47] fix(ci): openapi-security-tiers checker must honor routeGuard patterns + imported prefixes (#12350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado numa worktree sobre o tip de `release/v3.8.51`, medindo o gate dos dois lados: **red no tip** (dezenas de rotas `volcengine-plan`/`vnc-session` reportadas como "has x-loopback-only but is NOT covered") e **PASS com este PR**, exit 0. Como é um gate de segurança, confirmei que o fix torna o checker *preciso* e não *frouxo*. A afirmação central do PR — que uma rota é coberta se casar com um prefixo resolvido **ou** com um pattern — bate exatamente com o runtime (`src/server/authz/routeGuard.ts:252-255`): ```ts return ( LOCAL_ONLY_API_PREFIXES.some((p) => path === p || path.startsWith(p)) || LOCAL_ONLY_API_PATTERNS.some((re) => re.test(path)) ); ``` O checker antigo enxergava só o primeiro braço, e nem isso por completo: a captura `[^\]]+` quebrava no `]` dentro de classes de regex, então `LOCAL_ONLY_API_PATTERNS` não era parseado, e `VNC_ROUTE_PREFIX` (const importada, não literal) não era resolvido. Resultado: rotas efetivamente protegidas em runtime apareciam como desprotegidas. Nenhum achado real foi silenciado — as 95 linhas de `WARN — missing x-loopback-only annotation` continuam saindo, são explicitamente não-fatais e pré-existentes. Fecha um dos HARDs do base-red #12335. Obrigado, @ggiak. --- .../check/check-openapi-security-tiers.mjs | 174 +++++++++++++----- 1 file changed, 127 insertions(+), 47 deletions(-) diff --git a/scripts/check/check-openapi-security-tiers.mjs b/scripts/check/check-openapi-security-tiers.mjs index 812e4a0c1b..89d17c0482 100644 --- a/scripts/check/check-openapi-security-tiers.mjs +++ b/scripts/check/check-openapi-security-tiers.mjs @@ -1,9 +1,23 @@ #!/usr/bin/env node /** * Cross-references openapi.yaml x-loopback-only / x-always-protected annotations - * against the compile-time constants in src/server/authz/routeGuard.ts. + * against the compile-time route-classification constants in + * src/server/authz/routeGuard.ts. * - * Fails if any YAML annotation disagrees with the routeGuard.ts constants. + * routeGuard classifies a loopback-only route through TWO mechanisms, and this + * checker must honor BOTH or it reports false positives (regression #12335): + * + * 1. LOCAL_ONLY_API_PREFIXES — flat string prefixes. One entry + * (VNC_ROUTE_PREFIX) is an imported const rather than a string literal, so + * it is resolved from its source module. + * 2. LOCAL_ONLY_API_PATTERNS — RegExp entries for spawn-capable routes whose + * dynamic path parameter sits BEFORE the gated segment (e.g. + * /api/providers/{id}/login), which a flat prefix cannot target without + * over-broadening the whole /api/providers/ subtree. + * + * A route is "covered" iff it matches a resolved prefix OR a pattern — exactly + * the `isLocalOnlyPath()` runtime contract. Fails if any YAML annotation + * disagrees with the routeGuard.ts constants. */ import fs from "node:fs"; @@ -13,34 +27,116 @@ import * as yaml from "js-yaml"; const ROOT = process.cwd(); const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); const ROUTE_GUARD_PATH = path.join(ROOT, "src", "server", "authz", "routeGuard.ts"); +const guardSrc = fs.readFileSync(ROUTE_GUARD_PATH, "utf-8"); -function parseStringArray(match) { - if (!match) return []; - // Strip line comments before splitting — array entries in routeGuard.ts often - // carry inline `// T-XX:` annotations that would otherwise pollute the parsed tokens. - return match[1] - .replace(/\/\/[^\n]*/g, "") - .split(",") - .map((s) => s.trim().replace(/^["']|["']$/g, "")) - .filter(Boolean); +// Capture an exported array's body up to its closing `\n];`. Unlike a `[^\]]+` +// capture, this is immune to `]` characters inside comments or regex character +// classes (e.g. `[^/]`) — the exact footgun documented at routeGuard.ts's +// /api/oauth/cursor/auto-import entry, and the reason regex patterns could not +// be parsed at all before. +function extractArrayBody(name) { + const m = guardSrc.match( + new RegExp(`export const ${name}\\b[\\s\\S]*?=\\s*\\[([\\s\\S]*?)\\n\\];`) + ); + return m ? m[1] : null; } -const guardSrc = fs.readFileSync(ROUTE_GUARD_PATH, "utf-8"); -const LOCAL_ONLY_PREFIXES = parseStringArray( - guardSrc.match(/export const LOCAL_ONLY_API_PREFIXES.*?=\s*\[([^\]]+)\]/s) -); -const ALWAYS_PROTECTED_PATHS = parseStringArray( - guardSrc.match(/export const ALWAYS_PROTECTED_API_PATHS.*?=\s*\[([^\]]+)\]/s) -); +const stripLineComments = (s) => s.replace(/\/\/[^\n]*/g, ""); -if (LOCAL_ONLY_PREFIXES.length === 0 || ALWAYS_PROTECTED_PATHS.length === 0) { - console.error("[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants"); +function resolveModule(spec) { + let base; + if (spec.startsWith("@/")) base = path.join(ROOT, "src", spec.slice(2)); + else if (spec.startsWith(".")) base = path.resolve(path.dirname(ROUTE_GUARD_PATH), spec); + else throw new Error(`openapi-security-tiers: unsupported import specifier '${spec}'`); + for (const cand of [base, `${base}.ts`, `${base}.mts`, path.join(base, "index.ts")]) { + if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand; + } + throw new Error(`openapi-security-tiers: cannot resolve module '${spec}' (from ${base})`); +} + +// Resolve a bare identifier used inside a prefix array (e.g. VNC_ROUTE_PREFIX) +// to its string-literal value by following its import in routeGuard.ts. +function resolveIdentifier(ident) { + const imp = guardSrc.match( + new RegExp(`import\\s*(?:type\\s*)?\\{[^}]*\\b${ident}\\b[^}]*\\}\\s*from\\s*["']([^"']+)["']`) + ); + if (!imp) + throw new Error( + `openapi-security-tiers: '${ident}' used in a prefix array has no import in routeGuard.ts` + ); + const modSrc = fs.readFileSync(resolveModule(imp[1]), "utf-8"); + const lit = modSrc.match(new RegExp(`export const ${ident}\\s*=\\s*["']([^"']+)["']`)); + if (!lit) + throw new Error(`openapi-security-tiers: cannot resolve '${ident}' to a string literal`); + return lit[1]; +} + +// String prefixes: quoted entries pass through; bare identifiers are resolved. +function parsePrefixes(name) { + const body = extractArrayBody(name); + if (body == null) + throw new Error(`openapi-security-tiers: could not locate ${name} in routeGuard.ts`); + return stripLineComments(body) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((tok) => { + const unquoted = tok.replace(/^["']|["']$/g, ""); + return unquoted !== tok ? unquoted : resolveIdentifier(tok); + }); +} + +// RegExp patterns: one `/.../ ` literal per line. +function parsePatterns(name) { + const body = extractArrayBody(name); + if (body == null) + throw new Error(`openapi-security-tiers: could not locate ${name} in routeGuard.ts`); + const out = []; + for (const raw of body.split("\n")) { + const t = raw + .replace(/\/\/.*$/, "") + .trim() + .replace(/,\s*$/, "") + .trim(); + if (t.length > 2 && t.startsWith("/") && t.endsWith("/")) out.push(new RegExp(t.slice(1, -1))); + } + return out; +} + +const LOCAL_ONLY_PREFIXES = parsePrefixes("LOCAL_ONLY_API_PREFIXES"); +const LOCAL_ONLY_PATTERNS = parsePatterns("LOCAL_ONLY_API_PATTERNS"); +const ALWAYS_PROTECTED_PATHS = parsePrefixes("ALWAYS_PROTECTED_API_PATHS"); + +if ( + LOCAL_ONLY_PREFIXES.length === 0 || + LOCAL_ONLY_PATTERNS.length === 0 || + ALWAYS_PROTECTED_PATHS.length === 0 +) { + console.error( + `[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants ` + + `(prefixes=${LOCAL_ONLY_PREFIXES.length}, patterns=${LOCAL_ONLY_PATTERNS.length}, ` + + `alwaysProtected=${ALWAYS_PROTECTED_PATHS.length})` + ); process.exit(1); } +// OpenAPI template params ({id}, {sessionId}, …) → a concrete single non-slash +// segment, so pattern regexes written against resolved paths (`[^/]+`) match. +const concretize = (p) => p.replace(/\{[^}]+\}/g, "x"); + +const matchesPrefix = (concrete) => + LOCAL_ONLY_PREFIXES.some((prefix) => { + const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; + return concrete === norm || concrete.startsWith(`${norm}/`); + }); + +function coveredByLocalOnly(pathStr) { + const concrete = concretize(pathStr); + return matchesPrefix(concrete) || LOCAL_ONLY_PATTERNS.some((re) => re.test(concrete)); +} + const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")); const paths = raw.paths || {}; - const errors = []; for (const [pathStr, methods] of Object.entries(paths)) { @@ -48,17 +144,11 @@ for (const [pathStr, methods] of Object.entries(paths)) { for (const [method, spec] of Object.entries(methods)) { if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue; - if (spec["x-loopback-only"] === true) { - const matchesPrefix = LOCAL_ONLY_PREFIXES.some((prefix) => { - const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; - return pathStr === norm || pathStr.startsWith(norm + "/"); - }); - if (!matchesPrefix) { - errors.push( - `${method.toUpperCase()} ${pathStr}: has x-loopback-only but is NOT covered by ` + - `LOCAL_ONLY_API_PREFIXES [${LOCAL_ONLY_PREFIXES.join(", ")}]` - ); - } + if (spec["x-loopback-only"] === true && !coveredByLocalOnly(pathStr)) { + errors.push( + `${method.toUpperCase()} ${pathStr}: has x-loopback-only but is NOT covered by ` + + `LOCAL_ONLY_API_PREFIXES or LOCAL_ONLY_API_PATTERNS` + ); } if (spec["x-always-protected"] === true) { @@ -75,23 +165,13 @@ for (const [pathStr, methods] of Object.entries(paths)) { } } -// Reverse pass: every YAML path that falls under a LOCAL_ONLY prefix should -// carry `x-loopback-only: true` on every method, otherwise external API -// consumers have no signal that the route is loopback-restricted. Closes the -// "new spawn-capable route added without annotation" regression class. -// -// Currently reported as warnings (non-fatal) because the v3.8.4 release ships -// with a known annotation gap on /api/services/* and /api/cli-tools/runtime/* -// that will be patched in a follow-up doc-only PR. Promote to errors once the -// backlog is cleared. +// Reverse pass (non-fatal): every YAML path that falls under a LOCAL_ONLY prefix +// should carry `x-loopback-only`. Pattern-only routes are intentionally excluded +// — they are not "under" a broad prefix. Known annotation gaps stay warnings. const reverseWarnings = []; for (const [pathStr, methods] of Object.entries(paths)) { if (!methods || typeof methods !== "object") continue; - const fallsUnderLocalOnly = LOCAL_ONLY_PREFIXES.some((prefix) => { - const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; - return pathStr === norm || pathStr.startsWith(norm + "/"); - }); - if (!fallsUnderLocalOnly) continue; + if (!matchesPrefix(concretize(pathStr))) continue; for (const [method, spec] of Object.entries(methods)) { if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue; if (spec["x-loopback-only"] !== true) { @@ -105,7 +185,7 @@ for (const [pathStr, methods] of Object.entries(paths)) { if (reverseWarnings.length > 0) { console.warn( - `[openapi-security-tiers] WARN — ${reverseWarnings.length} LOCAL_ONLY paths missing x-loopback-only annotation (non-fatal, follow-up doc PR):` + `[openapi-security-tiers] WARN — ${reverseWarnings.length} LOCAL_ONLY paths missing x-loopback-only annotation (non-fatal):` ); reverseWarnings.forEach((w) => console.warn(` - ${w}`)); } From 49c4a620ca651227b97029852df31d8ab2db1595 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 3 Sep 2026 09:19:44 -0300 Subject: [PATCH 21/47] fix(authz): hard-gate every credential export and CLI-config write (GHSA-5926-2w35-7h4q) (#12600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(authz): hard-gate every credential export and CLI-config write GHSA-5926-2w35-7h4q: `POST /api/providers/{id}/claude-auth/export` and `.../codex-auth/export` gate on `requireManagementAuth(request)` with no `alwaysRequireAuth`, and neither path was in ALWAYS_PROTECTED_API_PATHS. Under `requireLogin=false` — the local-first default — both fail open, so anyone who knows a connection id downloads the operator's raw Claude/Codex OAuth access_token / refresh_token (plus the Codex id_token). This is the third recurrence of one class. GHSA-mghq-58h3-qcqj added /api/db-backups; GHSA-v7g9-7f55-5g46 added the /api/settings/*-json siblings mghq had missed; these two are the siblings both missed. So the fix is written against the class, not the two reported routes. Sweeping every route that hands out stored credentials, dumps captured traffic, or writes the operator's CLI config turned up four more on the fail-open tier: - GET /api/logs/export — dumps call_logs (prompts and responses) and proxy_logs for up to 168h. - /api/cli-tools/codex-profiles — GET leaks the operator's account label; PUT writes attacker-supplied auth.json and config.toml straight into the host's Codex CLI config. Its only guard is ensureCliConfigWriteAllowed() with no targetPath, which checks CLI_ALLOW_CONFIG_WRITES — default true. Paired with the POST that stores an arbitrary profile, that is: save a profile holding the attacker's auth.json, apply it, and the operator's CLI now runs on attacker credentials (or, via config.toml, an attacker base URL). - {claude,codex}-auth/apply-local and providers/agy-auth/apply-local — write a stored credential into ~/.codex/auth.json and ~/.gemini/antigravity-cli/antigravity-oauth-token. The traffic-inspector HAR exports were already covered by LOCAL_ONLY. Routes with a dynamic segment cannot be expressed in the exact/prefix list — a `/api/providers/` prefix would hard-gate the whole provider surface and break every keyless install — so this adds ALWAYS_PROTECTED_API_PATTERNS, mirroring the existing LOCAL_ONLY_API_PATTERNS, and `isAlwaysProtectedPath` consults both. The apply-local routes get ALWAYS_PROTECTED rather than LOCAL_ONLY on purpose: it closes the anonymous hole without breaking an operator driving the dashboard through a tunnel. Deliberately NOT adding `{ alwaysRequireAuth: true }` at the handlers. Tier 2 is the architecture's designated mechanism and the guard runs before the handler; a second copy of the same decision inside each route is exactly the kind of duplicate that drifts out of sync (cf. the dashboardCsrf prefix scan that had to be unified in #11417). tests/unit/authz/credential-export-always-protected.test.ts — 5 tests, red before the fix. Written as an inventory of the whole class rather than two more assertions, plus negative cases: the neighbouring provider routes must stay on MANAGEMENT, and a connection id containing a slash must not slip past `[^/]+`. openapi.yaml marks the seven newly-gated operations `x-always-protected`, and openapi-security-tiers.test.ts now resolves `{param}` placeholders so it can validate the pattern entries too. Reported by @skeletonsec. Closes GHSA-5926-2w35-7h4q * chore(quality): register the credential-export authz test in stryker tap.testFiles The new tests/unit/authz/credential-export-always-protected.test.ts covers src/server/authz/routeGuard.ts, so check:mutation-test-coverage --strict fails until it is listed — its mutant kills would not count otherwise. Inserted in place (no re-serialization: a JSON round-trip on this file reorders ~10 curated entries that are already out of alphabetical order, cf. #11438). --- docs/openapi.yaml | 10 ++ src/server/authz/routeGuard.ts | 41 +++++- stryker.conf.json | 1 + ...credential-export-always-protected.test.ts | 132 ++++++++++++++++++ tests/unit/openapi-security-tiers.test.ts | 26 +++- 5 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 tests/unit/authz/credential-export-always-protected.test.ts diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 2f83d88080..16a39eef8d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2297,6 +2297,7 @@ paths: post: tags: [Providers] summary: Auto-detect and import the local Antigravity CLI (agy) login from disk + x-always-protected: true responses: "200": description: Created or updated provider connection @@ -4050,12 +4051,14 @@ paths: get: tags: [CLI Tools] summary: Get Codex profiles + x-always-protected: true responses: "200": description: Codex profile list post: tags: [CLI Tools] summary: Create Codex profile + x-always-protected: true requestBody: required: true content: @@ -4068,6 +4071,7 @@ paths: put: tags: [CLI Tools] summary: Update Codex profile + x-always-protected: true requestBody: required: true content: @@ -4080,6 +4084,7 @@ paths: delete: tags: [CLI Tools] summary: Delete Codex profile + x-always-protected: true responses: "200": description: Profile deleted @@ -9751,6 +9756,7 @@ paths: tags: - Logs summary: "GET logs › export" + x-always-protected: true responses: "200": description: OK @@ -10230,6 +10236,7 @@ paths: tags: - Providers summary: "POST providers › › claude auth › apply local" + x-always-protected: true responses: "200": description: OK @@ -10238,6 +10245,7 @@ paths: tags: - Providers summary: "POST providers › › claude auth › export" + x-always-protected: true responses: "200": description: OK @@ -10246,6 +10254,7 @@ paths: tags: - Providers summary: "POST providers › › codex auth › apply local" + x-always-protected: true responses: "200": description: OK @@ -10254,6 +10263,7 @@ paths: tags: - Providers summary: "POST providers › › codex auth › export" + x-always-protected: true responses: "200": description: OK diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index 60a8d8e15a..b987f4fe49 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -140,6 +140,42 @@ export const ALWAYS_PROTECTED_API_PATHS: ReadonlyArray = [ // which is false under requireLogin=false. (GHSA-v7g9-7f55-5g46) "/api/settings/export-json", "/api/settings/import-json", + // Bulk log export: call_logs carries prompts and responses, proxy_logs carries + // client/public IPs, and the handler only calls requireManagementAuth() with no + // alwaysRequireAuth. Found sweeping the GHSA-5926-2w35-7h4q class. + "/api/logs/export", + // Codex CLI profile store. GET leaks the operator's account label; PUT writes + // attacker-supplied auth.json + config.toml straight into the operator's Codex + // CLI config (ensureCliConfigWriteAllowed() only checks CLI_ALLOW_CONFIG_WRITES, + // which defaults to true), so a POST+PUT pair repoints the CLI at attacker + // credentials or an attacker base URL. Found sweeping the same class. + "/api/cli-tools/codex-profiles", + // Writes into ~/.gemini/antigravity-cli/antigravity-oauth-token. Same family + // as the {claude,codex}-auth/apply-local pattern below; a plain path because + // it carries no dynamic segment. + "/api/providers/agy-auth/apply-local", +]; + +/** + * ALWAYS_PROTECTED routes whose path carries a dynamic segment, so the plain + * exact/prefix list above cannot express them: a `/api/providers/` prefix would + * hard-gate the entire provider surface and break every keyless local-first + * install. Mirrors LOCAL_ONLY_API_PATTERNS. + * + * The Claude/Codex OAuth export routes return the connection's raw + * access_token / refresh_token (and the Codex id_token) and gate only on + * `requireManagementAuth(request)` with no `alwaysRequireAuth`, which fails open + * under requireLogin=false (GHSA-5926-2w35-7h4q). They are the siblings that + * both GHSA-mghq-58h3-qcqj and GHSA-v7g9-7f55-5g46 missed. + */ +export const ALWAYS_PROTECTED_API_PATTERNS: ReadonlyArray = [ + // `export` hands the caller the raw token; `apply-local` writes it into the + // host's CLI config (~/.codex/auth.json and the Claude equivalent). The second + // does not disclose the credential, but "anonymous" is still the wrong + // audience for it. ALWAYS_PROTECTED rather than LOCAL_ONLY on purpose: it + // closes the anonymous hole without breaking an operator driving the dashboard + // through a tunnel. + /^\/api\/providers\/[^/]+\/(claude|codex)-auth\/(export|apply-local)\/?$/, ]; export function isLoopbackHost(hostHeader: string | null): boolean { @@ -295,5 +331,8 @@ export function isLocalOnlyBypassableByManageScope(path: string): boolean { } export function isAlwaysProtectedPath(path: string): boolean { - return ALWAYS_PROTECTED_API_PATHS.some((p) => path === p || path.startsWith(p)); + return ( + ALWAYS_PROTECTED_API_PATHS.some((p) => path === p || path.startsWith(p)) || + ALWAYS_PROTECTED_API_PATTERNS.some((re) => re.test(path)) + ); } diff --git a/stryker.conf.json b/stryker.conf.json index d13bf2c1d4..23dd4fb2d5 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -90,6 +90,7 @@ "tests/unit/auth-opencode-zen-noauth-fallback.test.ts", "tests/unit/auth-passthrough-per-model-402-12242.test.ts", "tests/unit/auth-terminal-status.test.ts", + "tests/unit/authz/credential-export-always-protected.test.ts", "tests/unit/authz/discovery-routes-local-only.test.ts", "tests/unit/authz/oauth-autoimport-local-only.test.ts", "tests/unit/authz/route-guard-local-prefix.test.ts", diff --git a/tests/unit/authz/credential-export-always-protected.test.ts b/tests/unit/authz/credential-export-always-protected.test.ts new file mode 100644 index 0000000000..c297bad003 --- /dev/null +++ b/tests/unit/authz/credential-export-always-protected.test.ts @@ -0,0 +1,132 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + isAlwaysProtectedPath, + isLocalOnlyPath, + ALWAYS_PROTECTED_API_PATHS, +} from "../../../src/server/authz/routeGuard.ts"; + +// GHSA-5926-2w35-7h4q — the Claude/Codex OAuth export routes gate on +// `requireManagementAuth(request)` with no `alwaysRequireAuth`, which fails open +// under requireLogin=false, and neither path was in ALWAYS_PROTECTED_API_PATHS. +// An unauthenticated caller who knows a connection id could download the +// operator's raw access_token / refresh_token / id_token. +// +// This is the THIRD recurrence of one class: GHSA-mghq-58h3-qcqj added +// /api/db-backups, GHSA-v7g9-7f55-5g46 added the /api/settings/*-json siblings +// it had missed, and this one is the siblings BOTH missed. So the test is +// written as an inventory of the whole class rather than two more assertions: +// a route that hands out stored credentials, dumps captured traffic, or writes +// the operator's CLI config must be hard-gated (ALWAYS_PROTECTED or +// LOCAL_ONLY), never left on the fail-open MANAGEMENT tier. + +const HARD_GATED_INVENTORY: ReadonlyArray<{ path: string; why: string }> = [ + // ── Reported in GHSA-5926-2w35-7h4q ────────────────────────────────────── + { + path: "/api/providers/6f3c1b7e-0000-4000-8000-000000000000/claude-auth/export", + why: "returns the connection's raw Claude OAuth access_token/refresh_token", + }, + { + path: "/api/providers/6f3c1b7e-0000-4000-8000-000000000000/codex-auth/export", + why: "returns the connection's raw Codex access_token/refresh_token/id_token", + }, + // ── Found sweeping the class while fixing the above ────────────────────── + { + path: "/api/logs/export", + why: "dumps call_logs (prompts and responses) and proxy_logs for up to 168h", + }, + { + path: "/api/cli-tools/codex-profiles", + why: "PUT writes attacker-supplied auth.json and config.toml into the operator's Codex CLI config", + }, + // ── Same family: WRITE the operator's credentials into host CLI files ─── + // These do not hand the credential to the caller, so they are a step below + // the export routes — but anonymous is still the wrong audience for "write + // this connection's token into ~/.codex/auth.json". ALWAYS_PROTECTED rather + // than LOCAL_ONLY on purpose: it closes the anonymous hole without breaking + // an operator driving the dashboard through a tunnel. + { + path: "/api/providers/6f3c1b7e-0000-4000-8000-000000000000/codex-auth/apply-local", + why: "writes the connection's credential into the host's ~/.codex/auth.json", + }, + { + path: "/api/providers/6f3c1b7e-0000-4000-8000-000000000000/claude-auth/apply-local", + why: "writes the connection's credential into the host's Claude CLI config", + }, + { + path: "/api/providers/agy-auth/apply-local", + why: "writes into ~/.gemini/antigravity-cli/antigravity-oauth-token", + }, + // ── Already fixed; pinned so a refactor cannot silently drop them ──────── + { path: "/api/db-backups/export", why: "GHSA-mghq-58h3-qcqj" }, + { path: "/api/db-backups/exportAll", why: "GHSA-mghq-58h3-qcqj" }, + { path: "/api/settings/export-json", why: "GHSA-v7g9-7f55-5g46" }, + { path: "/api/settings/import-json", why: "GHSA-v7g9-7f55-5g46" }, + { path: "/api/settings/database", why: "irreversible database replace" }, + { path: "/api/shutdown", why: "stops the server" }, + // ── Hard-gated by the LOCAL_ONLY tier instead ──────────────────────────── + { + path: "/api/tools/traffic-inspector/export.har", + why: "captured traffic can contain Authorization headers (LOCAL_ONLY)", + }, + { + path: "/api/tools/traffic-inspector/sessions/abc/export.har", + why: "same, per session (LOCAL_ONLY)", + }, +]; + +test("every credential/traffic export and CLI-config write is hard-gated", () => { + for (const { path, why } of HARD_GATED_INVENTORY) { + const gated = isAlwaysProtectedPath(path) || isLocalOnlyPath(path); + assert.ok( + gated, + `${path} is on the fail-open MANAGEMENT tier — anonymous under requireLogin=false. ${why}` + ); + } +}); + +test("the trailing-slash spelling is gated too", () => { + for (const path of [ + "/api/providers/abc/claude-auth/export/", + "/api/providers/abc/codex-auth/export/", + "/api/logs/export/", + "/api/cli-tools/codex-profiles/", + ]) { + assert.ok(isAlwaysProtectedPath(path) || isLocalOnlyPath(path), path); + } +}); + +test("the new patterns do not over-protect their neighbours", () => { + // The dynamic-segment entries must not swallow the rest of /api/providers/, + // which is ordinary MANAGEMENT and has to keep working under requireLogin=false. + for (const path of [ + "/api/providers", + "/api/providers/abc", + "/api/providers/abc/models", + "/api/providers/abc/claude-auth", + "/api/providers/abc/codex-auth", + "/api/providers/abc/claude-auth/apply", + "/api/providers/agy-auth", + "/api/logs", + "/api/cli-tools", + ]) { + assert.equal( + isAlwaysProtectedPath(path), + false, + `${path} must stay on the MANAGEMENT tier — hard-gating it breaks keyless local-first installs` + ); + } +}); + +test("a connection id cannot escape the pattern with a slash", () => { + // `[^/]+` is deliberate: a traversal-ish id must not match and silently drop + // back to the fail-open tier by looking like a different route. + assert.equal(isAlwaysProtectedPath("/api/providers/a/b/claude-auth/export"), false); +}); + +test("the plain-path allowlist keeps its existing entries", () => { + for (const p of ["/api/shutdown", "/api/settings/database", "/api/db-backups"]) { + assert.ok(ALWAYS_PROTECTED_API_PATHS.includes(p), p); + } +}); diff --git a/tests/unit/openapi-security-tiers.test.ts b/tests/unit/openapi-security-tiers.test.ts index 8203ab6f2d..d1380a5c08 100644 --- a/tests/unit/openapi-security-tiers.test.ts +++ b/tests/unit/openapi-security-tiers.test.ts @@ -7,8 +7,12 @@ import * as yaml from "js-yaml"; const ROOT = process.cwd(); const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); -const { LOCAL_ONLY_API_PREFIXES, LOCAL_ONLY_API_PATTERNS, ALWAYS_PROTECTED_API_PATHS } = - await import("../../src/server/authz/routeGuard.ts"); +const { + LOCAL_ONLY_API_PREFIXES, + LOCAL_ONLY_API_PATTERNS, + ALWAYS_PROTECTED_API_PATHS, + ALWAYS_PROTECTED_API_PATTERNS, +} = await import("../../src/server/authz/routeGuard.ts"); const raw: any = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")); const paths: Record = raw.paths || {}; @@ -131,12 +135,22 @@ test("every x-always-protected path matches ALWAYS_PROTECTED_API_PATHS in routeG for (const [method, spec] of Object.entries(methods as Record)) { if (!["get", "post", "put", "patch", "delete"].includes(method)) continue; if (spec?.["x-always-protected"] !== true) continue; - const matchesPath = (ALWAYS_PROTECTED_API_PATHS as ReadonlyArray).some( - (p: string) => pathStr === p || pathStr.startsWith(`${p}/`) - ); + // Routes with a dynamic segment cannot be expressed in the plain + // exact/prefix list, so routeGuard also carries ALWAYS_PROTECTED_API_PATTERNS + // (GHSA-5926-2w35-7h4q). Substitute a concrete value for the OpenAPI + // `{param}` placeholders before testing those. + const concretePath = pathStr.replace(/\{[^}]+\}/g, "sample-id"); + const matchesPath = + (ALWAYS_PROTECTED_API_PATHS as ReadonlyArray).some( + (p: string) => pathStr === p || pathStr.startsWith(`${p}/`) + ) || + (ALWAYS_PROTECTED_API_PATTERNS as ReadonlyArray).some((re) => + re.test(concretePath) + ); assert.ok( matchesPath, - `YAML path "${pathStr}" ${method.toUpperCase()} has x-always-protected but is NOT in ALWAYS_PROTECTED_API_PATHS. ` + + `YAML path "${pathStr}" ${method.toUpperCase()} has x-always-protected but is NOT in ALWAYS_PROTECTED_API_PATHS ` + + `nor matched by ALWAYS_PROTECTED_API_PATTERNS. ` + `Entries: ${(ALWAYS_PROTECTED_API_PATHS as ReadonlyArray).join(", ")}` ); } From 2c4ad3e557a74a20e6728f6e2625ebc09742768a Mon Sep 17 00:00:00 2001 From: Giorgos Giakoumettis Date: Thu, 3 Sep 2026 15:21:00 +0300 Subject: [PATCH 22/47] =?UTF-8?q?docs(readme):=20introduce=20OmniRouteTray?= =?UTF-8?q?=20=E2=80=94=20the=20macOS=20menu-bar=20companion=20(#12276)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aprovado pelo operador. Adiciona o OmniRouteTray (@zoispag) ao README — app de menu-bar para macOS, rotulado com honestidade como projeto da comunidade e não release oficial. Mudança só de markdown, sem tocar nada executável; inclui também dois ajustes de alinhamento na tabela de contatos. Obrigado, @ggiak. --- README.md | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a026f9c9c3..055f71a88b 100644 --- a/README.md +++ b/README.md @@ -724,6 +724,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
📦
npm (global)npm install -g omnirouteOne command, any OS 🐳 Dockerdocker run … diegosouzapw/omnirouteMulti-arch AMD64 + ARM64 🖥️ Desktop (Electron)npm run electron:buildNative window + system tray — Windows / macOS / Linux + 🎩 Menu-bar (OmniRouteTray)brew install --cask zoispag/tap/omniroute-traySupervises & auto-updates the server — macOS 💪 ARMnative arm64Raspberry Pi, ARM servers, Apple Silicon 📱 Android (Termux)pkg install nodejs && npx -y omnirouteRuns on your phone, 24/7, no root 📲 PWA"Add to Home Screen"Fullscreen, offline, installable from browser @@ -732,7 +733,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md) 🛠️ From sourcenpm install && npm run devHack on it, contribute -📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) · [Desktop](electron/README.md) · [Termux](docs/guides/TERMUX_GUIDE.md) · [PWA](docs/guides/PWA_GUIDE.md) · [OpenCode](docs/frameworks/OPENCODE.md) +📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) · [Desktop](electron/README.md) · [Menu-bar tray](https://github.com/zoispag/omniroute-tray) · [Termux](docs/guides/TERMUX_GUIDE.md) · [PWA](docs/guides/PWA_GUIDE.md) · [OpenCode](docs/frameworks/OPENCODE.md)
@@ -767,6 +768,42 @@ From inside the editor: open the **Extensions** view, search **"OmniRoute"**, cl
+### 🎩 New: OmniRouteTray — your gateway, living in the menu bar + +
+ +> `omniroute serve` is happiest when it's always on. **[OmniRouteTray](https://github.com/zoispag/omniroute-tray)** +> turns that into a set-and-forget menu-bar app for macOS: it starts the server, keeps it alive +> across reboots, updates it in place, and puts your live token budget one click away — **no +> terminal window left open, no `npm install -g omniroute` to babysit.** + +Built with [Tauri v2](https://v2.tauri.app/) (a Rust core the size of a rounding error), it ships +its own signed Node 24 runtime and manages an app-owned OmniRoute install, so it never fights your +global `node`/`bun`. It **shares your existing `~/.omniroute/` config and database** — so it's the +same OmniRoute you already run, just with a hat on. 🎩 + + + + + + + + +
What it doesHow
🟢 Supervises the serverSpawns omniroute serve, adopts an already-running instance instead of duplicating it
📊 Live usage at a glanceProvider quota bars, Claude session/weekly limits with reset countdowns, 30-day cost breakdown
🔄 Auto-updates in placeStaged install, atomic swap, rollback on failure — always on the newest release
🚀 Start on loginOptional launch at login; tray-only, no dock icon
🩺 Doctor & logsOne-click diagnostics and server log access
+ +```sh +brew install --cask zoispag/tap/omniroute-tray +``` + +Prefer a download? Grab the latest .dmg from +Releases. Source, issues and build +docs live at zoispag/omniroute-tray. +
💛 A community project by @zoispag — not an official OmniRoute release.
+ +
+ +
+ ## 🔒 Private & Local-First
From 9af3ec5112f7315b0404b337dfd34909a63abb9e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 3 Sep 2026 10:49:01 -0300 Subject: [PATCH 23/47] feat(video): redact transcript in the in-memory pending-request snapshot (#12430 item 6) (#12596) * feat(video): redact transcript fields in the in-memory pending-request snapshot (#12430 item 6) trackPendingRequest (open-sse/handlers/chatCore.ts) stored the raw client body (with video transcript/audioTranscript cues) under `clientRequest`, live-exposed via /api/usage/call-logs (pendingDetails), /api/logs/[id] and /api/conversations while a request is in-flight. P2a redacted the persisted detailed-log snapshot but not this in-memory copy. Add redactPendingBody() to videoBridgeSnapshotRedaction.ts (sibling to logClientRawRequestRedacted from P2a): when videoBridgeObserved, returns the redacted clone from redactVideoTranscriptFieldsForLog; otherwise returns the exact same reference. Wire it into the trackPendingRequest call site (chatCore.ts:934), keeping the file within its frozen 5976-line budget (5971 -> 5974). * feat(video): substring-redact transcript in derived-prompt dispatch logs (#12430 item 4) Extend applyVideoBridgeLogRedaction with a string-content branch: pipeline-strategy stages, smart-auto-pipeline, and context-handoff summaries embed the transcript as a substring of a rendered prompt string rather than an exact array part, so the existing exact part-array match silently skipped them. Adds a mutually-exclusive string branch (Array.isArray vs typeof === "string") that does a replaceAll of the trusted fullText literal against a lazily cloned message, reusing the existing rootClone/clonedContainers/clonedMessages clone-on-write pattern so siblings keep original references and the input is never mutated. --- open-sse/handlers/chatCore.ts | 7 +- open-sse/handlers/chatCore/attemptLogging.ts | 51 +++++ .../videoBridgeSnapshotRedaction.ts | 14 ++ .../videoBridgeSnapshotRedaction.test.ts | 59 +++++- ...eo-bridge-derived-prompt-redaction.test.ts | 184 ++++++++++++++++++ 5 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 tests/unit/video-bridge-derived-prompt-redaction.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d28711ce12..a50df1069b 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -360,7 +360,10 @@ import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity"; import { getCacheControlSettings } from "@/lib/cacheControlSettings"; import { guardrailRegistry } from "@/lib/guardrails"; import type { VideoBridgeLogRedactionEntry } from "@/lib/guardrails/videoBridge"; -import { logClientRawRequestRedacted } from "@/lib/guardrails/videoBridgeSnapshotRedaction"; +import { + logClientRawRequestRedacted, + redactPendingBody, +} from "@/lib/guardrails/videoBridgeSnapshotRedaction"; import { shouldPreserveCacheControl, resolveConnectionCacheOverride, @@ -928,7 +931,7 @@ export async function handleChatCore({ const pendingRequestId = trackPendingRequest(model, provider, pendingConnId, true, { clientEndpoint: clientRawRequest?.endpoint || "/v1/chat/completions", - clientRequest: clientRawRequest?.body ?? body, + clientRequest: redactPendingBody(clientRawRequest?.body ?? body, videoBridgeObserved), providerRequest: initialProviderRequest, stage: "registered", correlationId, diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts index 2a583896a7..65a492a541 100644 --- a/open-sse/handlers/chatCore/attemptLogging.ts +++ b/open-sse/handlers/chatCore/attemptLogging.ts @@ -50,6 +50,16 @@ import { attachLogMeta } from "./cacheUsageMeta.ts"; * never touches a part whose text differs — see * `tests/unit/video-bridge-log-redaction.test.ts`'s "Scenario A" test for the * reproduction this fixes. + * + * #12430 item 4 (P2c): a message's `content` can also be a plain STRING that + * embeds `fullText` as a SUBSTRING rather than an exact array part — derived + * dispatches (pipeline-strategy stages, smart-auto-pipeline, context-handoff + * summaries) all interpolate the transcript blob into a larger rendered + * prompt string before calling `handleSingleModel`. That string branch is + * mutually exclusive with the array branch (a message's `content` is one or + * the other, never both) and uses `String.prototype.replaceAll` against the + * trusted `fullText` literal to swap every occurrence — see + * `tests/unit/video-bridge-derived-prompt-redaction.test.ts`. */ export function applyVideoBridgeLogRedaction( body: unknown, @@ -78,6 +88,47 @@ export function applyVideoBridgeLogRedaction( const originalMessage = originalContainer[messageIndex]; if (!originalMessage || typeof originalMessage !== "object") continue; const originalContent = (originalMessage as Record).content; + + // Derived-prompt dispatches (pipeline-strategy stages, smart-auto-pipeline, + // context-handoff summaries — #12430 item 4) embed the transcript as a + // SUBSTRING of a plain string `content`, e.g. a rendered stage prompt or a + // `{HISTORY}`-interpolated handoff summary, never as an exact array part. + // Mutually exclusive with the array branch below: a message's `content` + // is either a string or an array, never both, so this and the + // `Array.isArray` check never both match the same message. + if (typeof originalContent === "string") { + if (!originalContent.includes(fullText)) continue; + + // Same lazy clone-on-write as the array branch: root -> container + // array -> this message. Siblings keep referencing the originals. + if (!rootClone) rootClone = { ...source }; + let containerClone = clonedContainers.get(container); + if (!containerClone) { + containerClone = [...originalContainer]; + clonedContainers.set(container, containerClone); + rootClone[container] = containerClone; + } + + const messageKey = `${container}:${messageIndex}`; + let messageClone = clonedMessages.get(messageKey); + if (!messageClone) { + messageClone = { ...(originalMessage as Record) }; + clonedMessages.set(messageKey, messageClone); + containerClone[messageIndex] = messageClone; + } + + // Re-read from the (possibly already-cloned) message so a second + // redaction entry matching the same string content composes with the + // first instead of clobbering it. `fullText` is a trusted literal + // (the `[Video description:...]` blob), so replaceAll(string, string) + // needs no regex and is safe. replaceAll (not replace): a stage/summary + // prompt can quote the transcript back more than once. + const currentText = + typeof messageClone.content === "string" ? messageClone.content : originalContent; + messageClone.content = currentText.replaceAll(fullText, redactedText); + redacted = true; + continue; + } if (!Array.isArray(originalContent)) continue; for (let partIndex = 0; partIndex < originalContent.length; partIndex++) { diff --git a/src/lib/guardrails/videoBridgeSnapshotRedaction.ts b/src/lib/guardrails/videoBridgeSnapshotRedaction.ts index 8738b47cfc..8324b4be90 100644 --- a/src/lib/guardrails/videoBridgeSnapshotRedaction.ts +++ b/src/lib/guardrails/videoBridgeSnapshotRedaction.ts @@ -133,3 +133,17 @@ export function logClientRawRequestRedacted( clientRawRequest.headers ); } + +/** + * Call-site wrapper for the `clientRequest` field stored by `trackPendingRequest` + * (open-sse/handlers/chatCore.ts): the sibling in-memory leak to + * `logClientRawRequestRedacted` above — same raw body, but live-exposed via + * /api/usage/call-logs (pendingDetails), /api/logs/[id] and /api/conversations + * while the request is in-flight, not just in the persisted detailed-log + * snapshot. Identical observed/non-observed branching: a non-observed request + * keeps the exact same reference (no clone); an observed one gets the redacted + * clone. + */ +export function redactPendingBody(clientRequest: unknown, videoBridgeObserved: boolean): unknown { + return videoBridgeObserved ? redactVideoTranscriptFieldsForLog(clientRequest) : clientRequest; +} diff --git a/tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts b/tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts index 3f48bee7dc..8ca1dbab9f 100644 --- a/tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts +++ b/tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts @@ -7,7 +7,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { redactVideoTranscriptFieldsForLog } from "../../../src/lib/guardrails/videoBridgeSnapshotRedaction.ts"; +import { + redactVideoTranscriptFieldsForLog, + redactPendingBody, +} from "../../../src/lib/guardrails/videoBridgeSnapshotRedaction.ts"; // Heavy import is fine here (test only, never in the production module under test) — used // solely to prove the local placeholder literal never drifts from the canonical P1 constant. import { VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER } from "../../../src/lib/guardrails/videoBridgeHelpers.ts"; @@ -205,3 +208,57 @@ test("the redaction placeholder matches the canonical P1 constant (no drift)", ( const part = contentAt(result, "messages", 0)[0]; assert.equal(part.transcript, VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER); }); + +// #12430 item 6 (P2c): the sibling in-memory leak. `trackPendingRequest` +// (open-sse/handlers/chatCore.ts) stores the same raw client body under +// `clientRequest`, live-exposed via /api/usage/call-logs (pendingDetails), +// /api/logs/[id] and /api/conversations while the request is in-flight. This +// helper is the guarded call-site wrapper chatCore.ts uses, mirroring +// logClientRawRequestRedacted's observed/non-observed branching. +test("redactPendingBody: observed=true delegates to redactVideoTranscriptFieldsForLog", () => { + const body = { + messages: [ + { + role: "user", + content: [ + { + type: "input_video", + video_url: "https://example.com/clip.mp4", + transcript: { cues: [{ text: "pending secret" }] }, + }, + ], + }, + ], + }; + + const result = redactPendingBody(body, true); + assert.notEqual( + result, + body, + "observed path must return a new structure, not the same reference" + ); + const part = contentAt(result, "messages", 0)[0]; + assert.equal(part.transcript, VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER); + assert.ok(!JSON.stringify(result).includes("pending secret")); + assert.deepEqual(result, redactVideoTranscriptFieldsForLog(body)); +}); + +test("redactPendingBody: observed=false returns the SAME reference unchanged", () => { + const body = { + messages: [ + { + role: "user", + content: [ + { + type: "input_video", + video_url: "https://example.com/clip.mp4", + transcript: { cues: [{ text: "not observed" }] }, + }, + ], + }, + ], + }; + + const result = redactPendingBody(body, false); + assert.equal(result, body, "non-observed path must return the exact same reference"); +}); diff --git a/tests/unit/video-bridge-derived-prompt-redaction.test.ts b/tests/unit/video-bridge-derived-prompt-redaction.test.ts new file mode 100644 index 0000000000..1e31e75ea1 --- /dev/null +++ b/tests/unit/video-bridge-derived-prompt-redaction.test.ts @@ -0,0 +1,184 @@ +// tests/unit/video-bridge-derived-prompt-redaction.test.ts +// P2c of #12150/#12430 (Video Bridge transcript retention — derived-prompt +// dispatch logs, item 4). +// +// Seam trace finding (decisive): videoBridgeLog is ALREADY threaded end-to-end +// to every nested handleChatCore — pipeline-strategy stages +// (src/domain/pipeline.ts::executeStage), smart-auto-pipeline, and +// context-handoff summaries (open-sse/services/contextHandoff.ts) — because +// all of them dispatch through the single P1b `handleSingleModel` closure and +// terminate in the SAME handleChatCore -> persistAttemptLogs -> +// applyVideoBridgeLogRedaction logging path. No plumbing/param changes were +// needed anywhere. +// +// The gap this file proves closed: those derived dispatches embed the +// transcript as a SUBSTRING of a plain STRING `content` message — +// `{ role: "user", content: }` — built by executeStage() +// (pipeline.ts:196-199 via prompts.ts interpolation) and by the +// context-handoff summary builders (contextHandoff.ts:415/729, `{HISTORY}` +// template substitution). Before this fix, applyVideoBridgeLogRedaction only +// matched ARRAY-content parts by exact text (`part.text === fullText`), so it +// silently skipped these string-content messages and the raw transcript +// persisted in the stage/summary sub-request call logs. +// +// This suite calls the real, already-exported `applyVideoBridgeLogRedaction` +// (open-sse/handlers/chatCore/attemptLogging.ts) directly — it is a pure +// function (no DB), so no persistAttemptLogs/DB harness is needed here; that +// integration-level proof already lives in +// tests/unit/video-bridge-log-redaction.test.ts. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { applyVideoBridgeLogRedaction } from "../../open-sse/handlers/chatCore/attemptLogging.ts"; +import type { VideoBridgeLogRedactionEntry } from "../../src/lib/guardrails/videoBridge.ts"; + +const SECRET = "secret words"; +const FULL_TEXT = `[Video description: transcript[source=client] ${SECRET}]`; +const REDACTED_TEXT = "[Video description: transcript[source=client] [redacted-video-transcript]]"; + +function entry( + overrides: Partial = {} +): VideoBridgeLogRedactionEntry { + return { + container: "messages", + messageIndex: 0, + partIndex: 0, + fullText: FULL_TEXT, + redactedText: REDACTED_TEXT, + ...overrides, + }; +} + +test("derived-prompt (pipeline stage): a string-content message with the transcript embedded as a substring is redacted, secret absent, surrounding prompt text intact", () => { + const body = { + model: "openai/gpt-x", + messages: [ + { role: "system", content: "You are a summarization stage." }, + { + role: "user", + content: `Summarize the following context.\n\n${FULL_TEXT}\n\nEnd of context.`, + }, + ], + }; + + const result = applyVideoBridgeLogRedaction(body, [ + entry({ messageIndex: 1, partIndex: 0 }), + ]) as typeof body; + + const redactedContent = result.messages[1].content; + assert.equal( + redactedContent, + `Summarize the following context.\n\n${REDACTED_TEXT}\n\nEnd of context.` + ); + assert.ok(!redactedContent.includes(SECRET), "the raw transcript must not survive redaction"); + assert.ok( + redactedContent.startsWith("Summarize the following context.\n\n"), + "surrounding prompt text before the blob must stay intact" + ); + assert.ok( + redactedContent.endsWith("\n\nEnd of context."), + "surrounding prompt text after the blob must stay intact" + ); + assert.equal(JSON.stringify(result).includes(SECRET), false); +}); + +test("derived-prompt (context-handoff summary): input container string content is redacted the same way as messages", () => { + const body = { + model: "openai/gpt-x", + input: [ + { + role: "user", + content: `Continue the conversation given this history.\n\n${FULL_TEXT}\n\nContinue now.`, + }, + ], + }; + + const result = applyVideoBridgeLogRedaction(body, [ + entry({ container: "input", messageIndex: 0, partIndex: 0 }), + ]) as typeof body; + + const redactedContent = result.input[0].content; + assert.equal( + redactedContent, + `Continue the conversation given this history.\n\n${REDACTED_TEXT}\n\nContinue now.` + ); + assert.ok(!redactedContent.includes(SECRET)); + assert.equal(JSON.stringify(result).includes(SECRET), false); +}); + +test("multiple occurrences of fullText within the same string are ALL replaced (replaceAll, not replace)", () => { + const body = { + messages: [ + { + role: "user", + content: `First mention: ${FULL_TEXT}\n\nQuoted back for grounding: ${FULL_TEXT}\n\nDone.`, + }, + ], + }; + + const result = applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 0, partIndex: 0 })]) as { + messages: Array<{ content: string }>; + }; + + const redactedContent = result.messages[0].content; + assert.equal( + redactedContent, + `First mention: ${REDACTED_TEXT}\n\nQuoted back for grounding: ${REDACTED_TEXT}\n\nDone.` + ); + assert.equal( + redactedContent.split(REDACTED_TEXT).length - 1, + 2, + "both occurrences must be replaced" + ); + assert.ok(!redactedContent.includes(SECRET)); +}); + +test("regression: the existing ARRAY-content exact-part-match path still redacts (no regression from the new string branch)", () => { + const body = { + messages: [ + { role: "system", content: "sys" }, + { + role: "user", + content: [ + { type: "text", text: "look at this video" }, + { type: "text", text: FULL_TEXT }, + ], + }, + ], + }; + + const result = applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 1, partIndex: 1 })]) as { + messages: Array<{ content: unknown }>; + }; + + const content = result.messages[1].content as Array<{ text: string }>; + assert.equal(content[1].text, REDACTED_TEXT); + assert.ok(!content[1].text.includes(SECRET)); + assert.equal(content[0].text, "look at this video", "sibling part must stay untouched"); +}); + +test("no mutation of the input object: the caller's body is byte-identical after redaction (string-content path)", () => { + const body = { + messages: [{ role: "user", content: `before ${FULL_TEXT} after` }], + }; + const snapshotBefore = JSON.parse(JSON.stringify(body)); + + applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 0, partIndex: 0 })]); + + assert.deepEqual(body, snapshotBefore, "the original body must never be mutated"); +}); + +test("non-matching string content is returned unchanged, with the SAME root reference (nothing redacted -> no clone allocated)", () => { + const body = { + messages: [{ role: "user", content: "nothing to see here, no transcript blob at all" }], + }; + + const result = applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 0, partIndex: 0 })]); + + assert.equal( + result, + body, + "when no fullText matches, the exact same object reference is returned" + ); +}); From 9ddb8e0a932473bff96c7979a827019e46c2854b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 3 Sep 2026 11:33:07 -0300 Subject: [PATCH 24/47] =?UTF-8?q?fix(docs):=20restore=20the=20Next=20build?= =?UTF-8?q?=20=E2=80=94=20REMOVED=5FPROVIDERS.md=20had=20no=20frontmatter?= =?UTF-8?q?=20(base-red=20#12581)=20(#12610)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `source.config.ts` feeds `docs/reference/**/*.md` to fumadocs-mdx, whose default schema requires a `title`. #12478 added `docs/reference/REMOVED_PROVIDERS.md` with no frontmatter block at all, so every production build died with: [MDX] invalid frontmatter in docs/reference/REMOVED_PROVIDERS.md: - title: Invalid input: expected string, received undefined That single missing block is what turns three release-green gates red at once — `Package artifact (npm pack policy)` fails on the build, and both `Tarball boot-smoke` and the packaged CLI checks are skipped for lack of a valid `dist/`. Fixes: - add the frontmatter block, matching the convention of its sibling reference docs (`title` / `version` / `lastUpdated`). - add `check:docs-frontmatter`, wired into `check:docs-all`, so the next doc added without a title fails in milliseconds instead of costing a full Next build and a red release branch. The gate reads its globs from `source.config.ts` rather than duplicating them, so a new docs directory cannot silently escape the check. Verified: the gate reports OK across all 122 compiled docs, fails (exit 1) when the frontmatter is removed, and `npm run check:docs-all` passes. --- docs/reference/REMOVED_PROVIDERS.md | 6 ++ package.json | 3 +- scripts/check/check-docs-frontmatter.mjs | 103 +++++++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 scripts/check/check-docs-frontmatter.mjs diff --git a/docs/reference/REMOVED_PROVIDERS.md b/docs/reference/REMOVED_PROVIDERS.md index e03283bd43..24c0aab7d4 100644 --- a/docs/reference/REMOVED_PROVIDERS.md +++ b/docs/reference/REMOVED_PROVIDERS.md @@ -1,3 +1,9 @@ +--- +title: "Removed Providers" +version: 3.8.51 +lastUpdated: 2026-09-03 +--- + # Providers removed at their operator's request Some services were integrated into OmniRoute and later removed because the people who run diff --git a/package.json b/package.json index bbfee22bc7..bc63455358 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,8 @@ "check:router-eval": "node --import tsx scripts/check/check-router-eval-regression.ts", "check:doc-links": "node scripts/check/check-doc-links.mjs", "check:fabricated-docs": "node scripts/check/check-fabricated-docs.mjs --strict", - "check:docs-all": "npm run check:docs-sync && npm run check:docs-counts && npm run check:env-doc-sync && npm run check:deprecated-versions && npm run check:doc-links && npm run check:fabricated-docs", + "check:docs-frontmatter": "node scripts/check/check-docs-frontmatter.mjs", + "check:docs-all": "npm run check:docs-sync && npm run check:docs-frontmatter && npm run check:docs-counts && npm run check:env-doc-sync && npm run check:deprecated-versions && npm run check:doc-links && npm run check:fabricated-docs", "docs:render-diagrams": "node scripts/docs/render-diagrams.mjs", "i18n:run": "node scripts/i18n/run-translation.mjs", "i18n:run:dry": "node scripts/i18n/run-translation.mjs --dry-run", diff --git a/scripts/check/check-docs-frontmatter.mjs b/scripts/check/check-docs-frontmatter.mjs new file mode 100644 index 0000000000..a00751f880 --- /dev/null +++ b/scripts/check/check-docs-frontmatter.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * Validates the frontmatter of every Markdown file that fumadocs-mdx compiles. + * + * Why this gate exists: `source.config.ts` feeds `docs/**` globs to + * `defineDocs()`, and fumadocs' default frontmatter schema REQUIRES a `title` + * string. A doc added without frontmatter does not fail any docs gate — it + * fails the **production build** with a generic Turbopack error + * (`[MDX] invalid frontmatter … title: Invalid input: expected string, + * received undefined`), which then cascades into `check:pack-artifact` and the + * tarball boot-smoke. That is exactly how #12478 turned the release branch red + * (base-red #12581): one new reference doc, no frontmatter, three failing + * gates and an unbuildable branch. + * + * Catching it here costs milliseconds instead of a full Next build. + * + * The globs are read from `source.config.ts` rather than duplicated, so adding + * a new docs directory there cannot silently escape this check. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const CONFIG_PATH = path.join(ROOT, "source.config.ts"); + +/** Extract the `files: [...]` globs declared in source.config.ts. */ +function readConfiguredGlobs() { + const src = fs.readFileSync(CONFIG_PATH, "utf-8"); + const block = src.match(/files\s*:\s*\[([\s\S]*?)\]/); + if (!block) { + console.error( + "[docs-frontmatter] FAIL — could not locate the `files:` globs in source.config.ts" + ); + process.exit(1); + } + const globs = [...block[1].matchAll(/["'`]([^"'`]+)["'`]/g)].map((m) => m[1]); + if (globs.length === 0) { + console.error("[docs-frontmatter] FAIL — source.config.ts declares no doc globs"); + process.exit(1); + } + return globs; +} + +/** "./reference/**\/*.md" -> the directory under docs/ it covers. */ +function globToDir(glob) { + const cleaned = glob.replace(/^\.\//, ""); + const dir = cleaned.split("/**")[0]; + return path.join(ROOT, "docs", dir); +} + +function walkMarkdown(dir) { + if (!fs.existsSync(dir)) return []; + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkMarkdown(full)); + else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full); + } + return out; +} + +const violations = []; +const files = [...new Set(readConfiguredGlobs().flatMap((g) => walkMarkdown(globToDir(g))))]; + +for (const file of files) { + const rel = path.relative(ROOT, file); + const text = fs.readFileSync(file, "utf-8"); + + if (!text.startsWith("---")) { + violations.push(`${rel}: no frontmatter block (fumadocs requires a \`title\`)`); + continue; + } + const end = text.indexOf("\n---", 3); + if (end === -1) { + violations.push(`${rel}: frontmatter block is never closed`); + continue; + } + const frontmatter = text.slice(3, end); + const title = frontmatter.match(/^\s*title\s*:\s*(.+)$/m); + if (!title) { + violations.push(`${rel}: frontmatter has no \`title\``); + } else if (title[1].trim().replace(/^["']|["']$/g, "") === "") { + violations.push(`${rel}: \`title\` is empty`); + } +} + +if (violations.length > 0) { + console.error( + `[docs-frontmatter] FAIL — ${violations.length} doc(s) would break the Next build:` + ); + for (const v of violations) console.error(` - ${v}`); + console.error( + "\nEvery Markdown file matched by source.config.ts is compiled by fumadocs-mdx and needs a\n" + + 'frontmatter block with a title, e.g.:\n\n---\ntitle: "Removed Providers"\nversion: 3.8.51\nlastUpdated: 2026-09-03\n---\n' + ); + process.exit(1); +} + +console.log( + `[docs-frontmatter] OK — ${files.length} compiled doc(s) carry a valid frontmatter title.` +); From c9fb06e26ca78b4cdcee515d5cee364e2119b76e Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Thu, 3 Sep 2026 11:37:54 -0400 Subject: [PATCH 25/47] fix(grok-cli): treat omitted SuperGrokPro creditUsagePercent as 0% (#12312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca. O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. --- .../fixes/12312-grok-cli-supergrok-quota.md | 1 + open-sse/services/usage/grokCli.ts | 9 +- tests/unit/grok-cli-provider-limits.test.ts | 92 +++++++++++++++++-- 3 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 changelog.d/fixes/12312-grok-cli-supergrok-quota.md diff --git a/changelog.d/fixes/12312-grok-cli-supergrok-quota.md b/changelog.d/fixes/12312-grok-cli-supergrok-quota.md new file mode 100644 index 0000000000..1fed5df39e --- /dev/null +++ b/changelog.d/fixes/12312-grok-cli-supergrok-quota.md @@ -0,0 +1 @@ +- **fix(grok-cli):** treat omitted SuperGrokPro `creditUsagePercent` as 0% used so Provider Limits still renders a weekly bar (proto3 zero-elision) ([#12312](https://github.com/diegosouzapw/OmniRoute/pull/12312)) — thanks @HouMinXi diff --git a/open-sse/services/usage/grokCli.ts b/open-sse/services/usage/grokCli.ts index 08396cfb2f..bbd76199b8 100644 --- a/open-sse/services/usage/grokCli.ts +++ b/open-sse/services/usage/grokCli.ts @@ -239,9 +239,12 @@ export async function getGrokCliUsage(accessToken?: string) { const config = billing.config; const resetAt = config.currentPeriod?.end || null; const quotas: Record> = {}; - if (config.creditUsagePercent != null) { - quotas.weekly = percentageQuota(config.creditUsagePercent, resetAt); - } + // SuperGrokPro (and proto3 omit-zero) billing configs often omit + // creditUsagePercent / productUsage. A present config object is a + // successful billing read, so treat a missing percent as 0% used and + // still render a weekly bar. A missing config still returns + // "Grok Build billing status unavailable" above — that path is unchanged. + quotas.weekly = percentageQuota(config.creditUsagePercent ?? 0, resetAt); Object.assign(quotas, buildProductQuotas(config.productUsage, resetAt)); const autoTopUpResponse = userId diff --git a/tests/unit/grok-cli-provider-limits.test.ts b/tests/unit/grok-cli-provider-limits.test.ts index 081d403f5f..72e7c2b2da 100644 --- a/tests/unit/grok-cli-provider-limits.test.ts +++ b/tests/unit/grok-cli-provider-limits.test.ts @@ -38,6 +38,10 @@ function successFixtures( userId?: unknown; prepaidBalance?: Record | null | undefined; productUsage?: unknown; + creditUsagePercent?: number | null; + omitCreditUsagePercent?: boolean; + omitProductUsage?: boolean; + currentPeriod?: Record | null; } = {} ) { const tier = "tier" in options ? options.tier : "SuperGrok Heavy"; @@ -51,6 +55,14 @@ function successFixtures( { product: "API", usagePercent: 12.5 }, { product: "Grok Code", usagePercent: 44 }, ]; + const currentPeriod = + "currentPeriod" in options + ? options.currentPeriod + : { + type: "WEEKLY", + start: "2026-07-27T00:00:00.000Z", + end: "2026-08-03T00:00:00.000Z", + }; return async (input: string | URL | Request) => { const url = String(input); @@ -64,13 +76,14 @@ function successFixtures( if (url.endsWith("/billing?format=credits")) { return response({ config: { - creditUsagePercent: 37.25, - currentPeriod: { - type: "WEEKLY", - start: "2026-07-27T00:00:00.000Z", - end: "2026-08-03T00:00:00.000Z", - }, - productUsage, + ...(options.omitCreditUsagePercent + ? {} + : { + creditUsagePercent: + "creditUsagePercent" in options ? options.creditUsagePercent : 37.25, + }), + ...(currentPeriod === undefined ? {} : { currentPeriod }), + ...(options.omitProductUsage ? {} : { productUsage }), ...(prepaidBalance === undefined ? {} : { prepaidBalance }), }, }); @@ -492,3 +505,68 @@ test("Provider Limits cache persists only the public Grok billing contract", () test("grok-cli is registered on the public Provider Limits usage seam", () => { assert.ok((USAGE_FETCHER_PROVIDERS as readonly string[]).includes("grok-cli")); }); + +test("SuperGrokPro omitted creditUsagePercent still yields a weekly quota bar", async () => { + const usage = await getUsage( + successFixtures({ + tier: "SuperGrokPro", + omitCreditUsagePercent: true, + omitProductUsage: true, + prepaidBalance: { val: 0 }, + }) as typeof fetch + ); + + assert.equal(usage.plan, "SuperGrokPro"); + assert.deepEqual(usage.quotas?.weekly, { + used: 0, + total: 100, + remaining: 100, + remainingPercentage: 100, + resetAt: "2026-08-03T00:00:00.000Z", + isPercentageOnly: true, + }); + assert.equal(usage.message, undefined); +}); + +test("SuperGrokPro explicit null creditUsagePercent still yields a weekly quota bar", async () => { + const usage = await getUsage( + successFixtures({ + tier: "SuperGrokPro", + creditUsagePercent: null, + omitProductUsage: true, + prepaidBalance: { val: 0 }, + }) as typeof fetch + ); + + assert.equal(usage.plan, "SuperGrokPro"); + assert.deepEqual(usage.quotas?.weekly, { + used: 0, + total: 100, + remaining: 100, + remainingPercentage: 100, + resetAt: "2026-08-03T00:00:00.000Z", + isPercentageOnly: true, + }); +}); + +test("SuperGrokPro omitted currentPeriod still yields a weekly bar with null resetAt", async () => { + const usage = await getUsage( + successFixtures({ + tier: "SuperGrokPro", + omitCreditUsagePercent: true, + omitProductUsage: true, + currentPeriod: null, + prepaidBalance: { val: 0 }, + }) as typeof fetch + ); + + assert.equal(usage.plan, "SuperGrokPro"); + assert.deepEqual(usage.quotas?.weekly, { + used: 0, + total: 100, + remaining: 100, + remainingPercentage: 100, + resetAt: null, + isPercentageOnly: true, + }); +}); From 0f5fc78d8acbaa6d178b1e2d22e7e8badcedafa6 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Thu, 3 Sep 2026 11:38:16 -0400 Subject: [PATCH 26/47] feat(providers): search connections by name and baseUrl (#12495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca. O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. --- .../12108-provider-search-name-baseurl.md | 1 + .../providers/[id]/connectionsSearchFilter.ts | 9 +- .../(dashboard)/dashboard/providers/page.tsx | 54 +++++++---- .../dashboard/providers/providerPageUtils.ts | 43 ++++++++- ...r-search-connection-identity-12108.test.ts | 92 +++++++++++++++++++ .../unit/ui/connectionsSearchFilter.test.tsx | 19 ++++ 6 files changed, 197 insertions(+), 21 deletions(-) create mode 100644 changelog.d/features/12108-provider-search-name-baseurl.md create mode 100644 tests/unit/provider-search-connection-identity-12108.test.ts diff --git a/changelog.d/features/12108-provider-search-name-baseurl.md b/changelog.d/features/12108-provider-search-name-baseurl.md new file mode 100644 index 0000000000..a13f2a12a1 --- /dev/null +++ b/changelog.d/features/12108-provider-search-name-baseurl.md @@ -0,0 +1 @@ +- **feat(providers):** dashboard search matches connection name and `baseUrl` so imported OpenAI-compat nodes surface on the provider card ([#12108](https://github.com/diegosouzapw/OmniRoute/issues/12108)) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/connectionsSearchFilter.ts b/src/app/(dashboard)/dashboard/providers/[id]/connectionsSearchFilter.ts index 8c676c74be..6c7209b1aa 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/connectionsSearchFilter.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/connectionsSearchFilter.ts @@ -4,7 +4,8 @@ * * Case-insensitive, plain SUBSTRING match (mirrors the semantics of * `src/shared/utils/modelCatalogSearch.ts` — do not reimplement a fuzzy - * matcher here). Matches against id, tag, name, and email. + * matcher here). Matches against id, tag, name, email, and + * providerSpecificData.baseUrl (#12108). */ import type { ConnectionRowConnection } from "./components/ConnectionRow"; @@ -17,6 +18,11 @@ function getConnectionTag(conn: ConnectionRowConnection): string { return typeof tag === "string" ? tag : ""; } +function getConnectionBaseUrl(conn: ConnectionRowConnection): string { + const baseUrl = conn.providerSpecificData?.baseUrl; + return typeof baseUrl === "string" ? baseUrl : ""; +} + /** True when `conn` matches `query` (empty/whitespace query always matches). */ export function matchesAccountQuery(query: string, conn: ConnectionRowConnection): boolean { const normalizedQuery = normalize(query); @@ -27,6 +33,7 @@ export function matchesAccountQuery(query: string, conn: ConnectionRowConnection normalize(getConnectionTag(conn)), normalize(conn.name), normalize(conn.email), + normalize(getConnectionBaseUrl(conn)), ]; return haystacks.some((haystack) => haystack.includes(normalizedQuery)); } diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index a65478dd2a..a9cde7c6f7 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -558,7 +558,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const rawNoAuthEntriesAll = buildStaticProviderEntries("no-auth", getProviderStats); @@ -576,7 +577,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const apiKeyProviderEntriesAll = buildStaticProviderEntries("apikey", getProviderStats); @@ -595,7 +597,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const aggregatorProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => AGGREGATOR_PROVIDER_IDS.has(entry.providerId) @@ -607,7 +610,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const imageProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId) @@ -619,7 +623,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const enterpriseProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId) @@ -631,7 +636,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const videoProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => VIDEO_PROVIDER_IDS.has(entry.providerId) @@ -643,7 +649,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const embeddingRerankProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId) @@ -655,7 +662,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats); @@ -666,7 +674,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const localProviderEntriesAll = buildStaticProviderEntries("local", getProviderStats); @@ -677,7 +686,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const searchProviderEntriesAll = buildStaticProviderEntries("search", getProviderStats); @@ -688,7 +698,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const audioProviderEntriesAll = buildStaticProviderEntries("audio", getProviderStats); @@ -699,7 +710,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats); @@ -710,7 +722,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats); @@ -721,7 +734,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const compatibleProviderEntriesAll = [ @@ -754,7 +768,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const staticProviderEntriesAll = dedupeProviderEntries([ @@ -780,7 +795,8 @@ function ProvidersPageContent() { undefined, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); // IDE providers: subset of oauth/apikey providers that are editors/IDEs with @@ -796,7 +812,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const oauthOnlyEntriesAll = oauthProviderEntriesAll @@ -817,7 +834,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const compactProviderEntries = buildCompactProviderEntriesForPage({ diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 8c7be96c71..8356a1f9e5 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -421,6 +421,27 @@ function getFilterableModelsForEntry( return [...staticModels, ...liveModels]; } +/** + * Dashboard-card search identity for an imported connection (#12108). + * Only `name` and `providerSpecificData.baseUrl` — those are the two + * fields the issue asked for. id/tag/email stay on the detail-page + * haystack (`matchesAccountQuery`); surfacing a provider card from an + * account email would mix account-picker UX into the catalog filter. + */ +export type ProviderSearchConnection = { + provider?: string | null; + name?: string | null; + providerSpecificData?: Record | null; +}; + +function connectionSearchHaystacks(conn: ProviderSearchConnection): string[] { + const baseUrl = conn.providerSpecificData?.baseUrl; + return [ + typeof conn.name === "string" ? conn.name : "", + typeof baseUrl === "string" ? baseUrl : "", + ]; +} + export function filterConfiguredProviderEntries( entries: ProviderEntry[], showConfiguredOnly: boolean, @@ -428,7 +449,8 @@ export function filterConfiguredProviderEntries( showFreeOnly?: boolean, modelSearchQuery?: string, serviceKindFilter?: string | null, - liveModelsByProviderId?: LiveModelsByProviderId + liveModelsByProviderId?: LiveModelsByProviderId, + connections?: ProviderSearchConnection[] ): ProviderEntry[] { let filtered = entries; @@ -461,9 +483,26 @@ export function filterConfiguredProviderEntries( if (searchQuery && searchQuery.trim()) { filtered = filtered.filter((entry) => { const provider = entry.provider as Record; - return ( + if ( matchesAnyToken(String(provider.name || ""), searchQuery) || matchesAnyToken(entry.providerId, searchQuery) + ) { + return true; + } + // #12108: imported connections live under the canonical provider card. + // Match their operator-visible name / baseUrl so "Grade-S-Node" or an + // IP in the search box surfaces the OpenAI card instead of vanishing. + // Same matcher as provider.name / providerId above (matchesAnyToken: + // full-string first, then whitespace-token OR). The detail page uses + // a single-substring haystack — that is a different surface, not a + // bug in this filter. + if (!connections || connections.length === 0) return false; + return connections.some( + (conn) => + connectionBelongsToProviderPage(conn.provider, entry.providerId) && + connectionSearchHaystacks(conn).some((haystack) => + matchesAnyToken(haystack, searchQuery) + ) ); }); } diff --git a/tests/unit/provider-search-connection-identity-12108.test.ts b/tests/unit/provider-search-connection-identity-12108.test.ts new file mode 100644 index 0000000000..e6d8d5a556 --- /dev/null +++ b/tests/unit/provider-search-connection-identity-12108.test.ts @@ -0,0 +1,92 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { filterConfiguredProviderEntries } = await import( + "../../src/app/(dashboard)/dashboard/providers/providerPageUtils.ts" +); + +const ENTRIES = [ + { + providerId: "openai", + provider: { id: "openai", name: "OpenAI" }, + stats: { total: 1 }, + displayAuthType: "apikey" as const, + toggleAuthType: "apikey" as const, + }, + { + providerId: "claude", + provider: { id: "claude", name: "Claude" }, + stats: { total: 0 }, + displayAuthType: "oauth" as const, + toggleAuthType: "oauth" as const, + }, +]; + +const CONNECTIONS = [ + { + provider: "openai", + name: "Grade-S-Node", + providerSpecificData: { baseUrl: "http://145.10.20.30:8080" }, + }, +]; + +function ids(query: string, connections = CONNECTIONS) { + return filterConfiguredProviderEntries( + ENTRIES, + false, + query, + false, + "", + null, + undefined, + connections + ).map((e) => e.providerId); +} + +test("#12108 top-level search matches connection name (imported Grade-S-Node)", () => { + assert.deepEqual(ids("Grade-S-Node"), ["openai"]); +}); + +test("#12108 top-level search matches connection baseUrl host", () => { + assert.deepEqual(ids("145.10.20.30"), ["openai"]); +}); + +test("#12108 top-level search still matches static provider name", () => { + assert.deepEqual(ids("claude"), ["claude"]); +}); + +test("#12108 top-level search without connections does not invent a name match", () => { + assert.deepEqual(ids("Grade-S-Node", []), []); + const withoutArg = filterConfiguredProviderEntries(ENTRIES, false, "Grade-S-Node").map( + (e) => e.providerId + ); + assert.deepEqual(withoutArg, []); +}); + +test("#12108 empty search still returns every entry", () => { + assert.deepEqual(new Set(ids("")), new Set(["openai", "claude"])); +}); + +test("#12108 a connection on openai does not surface claude", () => { + assert.equal(ids("Grade-S-Node").includes("claude"), false); +}); + +test("#12108 dashboard card search does not match connection email/tag/id", () => { + const withAccountFields = [ + { + provider: "openai", + name: "Grade-S-Node", + id: "conn-grade", + email: "ops@grade.example", + providerSpecificData: { tag: "prod-east", baseUrl: "http://145.10.20.30:8080" }, + }, + ]; + assert.deepEqual(ids("ops@grade.example", withAccountFields), []); + assert.deepEqual(ids("prod-east", withAccountFields), []); + assert.deepEqual(ids("conn-grade", withAccountFields), []); + assert.deepEqual(ids("Grade-S-Node", withAccountFields), ["openai"]); +}); + +test("#12108 connection haystack uses matchesAnyToken (token OR, same as provider.name)", () => { + assert.deepEqual(ids("Grade Node"), ["openai"]); +}); diff --git a/tests/unit/ui/connectionsSearchFilter.test.tsx b/tests/unit/ui/connectionsSearchFilter.test.tsx index 6885c1a91c..3b2e9ab7ce 100644 --- a/tests/unit/ui/connectionsSearchFilter.test.tsx +++ b/tests/unit/ui/connectionsSearchFilter.test.tsx @@ -35,6 +35,11 @@ const CONNECTIONS: ConnectionRowConnection[] = [ { id: "conn-2", name: "Bob", email: "bob@example.com", providerSpecificData: { tag: "staging" } }, { id: "conn-3", name: "Carol", email: "carol@gmail.com" }, { id: "special-id-9", name: undefined, email: undefined }, + { + id: "conn-grade", + name: "Grade-S-Node", + providerSpecificData: { tag: "relay", baseUrl: "http://145.10.20.30:8080" }, + }, ]; describe("matchesAccountQuery / filterConnectionsByQuery — #7937", () => { @@ -74,6 +79,20 @@ describe("matchesAccountQuery / filterConnectionsByQuery — #7937", () => { it("does not match a connection missing the queried field", () => { expect(matchesAccountQuery("anything", CONNECTIONS[3])).toBe(false); }); + + // #12108 — detail-page search must also match providerSpecificData.baseUrl + // (import stores the override there; id/tag/name/email never contain the host). + it("matches providerSpecificData.baseUrl by host substring (#12108)", () => { + expect(matchesAccountQuery("145.10.20.30", CONNECTIONS[4])).toBe(true); + expect(matchesAccountQuery("145.10.20.30", CONNECTIONS[0])).toBe(false); + expect(filterConnectionsByQuery("145.10.20.30", CONNECTIONS).map((c) => c.id)).toEqual([ + "conn-grade", + ]); + }); + + it("matches providerSpecificData.baseUrl case-insensitively (#12108)", () => { + expect(matchesAccountQuery("HTTP://145.10.20.30:8080", CONNECTIONS[4])).toBe(true); + }); }); // --------------------------------------------------------------------------- From 52456a1cea2f0bdbeae7af2f1c6410f6e47495f5 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Thu, 3 Sep 2026 11:38:39 -0400 Subject: [PATCH 27/47] fix(quota): drop generic quota cache on upstream 429 (#12325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca. O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. --- .../12325-generic-quota-429-invalidate.md | 1 + config/quality/file-size-baseline.json | 6 +- open-sse/handlers/chatCore.ts | 9 + open-sse/services/genericQuotaFetcher.ts | 169 ++++++++++- .../antigravity-429-quota-cooldown.test.ts | 5 + tests/unit/generic-quota-fetcher.test.ts | 276 +++++++++++++++++- 6 files changed, 453 insertions(+), 13 deletions(-) create mode 100644 changelog.d/fixes/12325-generic-quota-429-invalidate.md diff --git a/changelog.d/fixes/12325-generic-quota-429-invalidate.md b/changelog.d/fixes/12325-generic-quota-429-invalidate.md new file mode 100644 index 0000000000..753e96027d --- /dev/null +++ b/changelog.d/fixes/12325-generic-quota-429-invalidate.md @@ -0,0 +1 @@ +- **fix(quota):** drop the generic quota cache (agy / Antigravity / Claude OAuth) on an upstream 429 so reset-aware scoring does not keep a 60s stale snapshot, and force-refresh the next usage fetch so inner provider caches cannot recache the same window ([#12325](https://github.com/diegosouzapw/OmniRoute/pull/12325)) — thanks @HouMinXi diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index c4e3bd4361..ffed35fbc1 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_09_02_12325_generic_429_invalidate": "PR #12325 own growth: open-sse/handlers/chatCore.ts 5946->5955 (+9 = the non-Codex 429 else-if that drops the generic quota wrapper and stamps force-refresh, plus a source-regex breadcrumb). Irreducible call-site wiring next to the existing Codex 429 invalidateCodexQuotaCache branch; not extractable without splitting handleChatCore mid-response. Covered by tests/unit/generic-quota-fetcher.test.ts (31/31) and tests/unit/antigravity-429-quota-cooldown.test.ts.", "_rebaseline_2026_09_02_12429_wreq_migration_suite": "PR #12429 (wreq-js web-cookie transport): new test file tests/unit/tls-client-wreq-migration.test.ts at 1374 lines, above the 1200 new-file testCap. Frozen rather than split: it is the single cohesive regression suite for the transport migration (31 cases covering streaming, fragmented EOF sentinels, proxy isolation, first-byte and hard deadlines, binary responses and cancellation), and the cases share the native-transport harness the file sets up once. Splitting it during a merge would duplicate that harness across files for no coverage gain. Entered at the exact LOC, so it can only ratchet down from here.", "_rebaseline_2026_09_02_12239_chatgpt_web_cleanroom": "PR #12239 (backryun, codex/restore-chatgpt-web-cleanroom) own growth at the two existing chat chokepoints for the clean-room ChatGPT Web transport: src/sse/handlers/chat.ts 2384->2424 (+40); open-sse/handlers/chatCore.ts 5946->5976 (+30). Additive dispatch wiring; the retirement guard is narrowed to the GPL-derived cgpt-web alias rather than removed, so #11754's provenance decision still holds for the old implementation. Same own-growth rationale as _rebaseline_2026_08_20_10531_freebuff_provider.", "_rebaseline_2026_09_02_12412_grok_web_prettier": "PR #12412 (repository Prettier style applied to tests/unit/grok-web.test.ts): the reformat expands the file +277 lines (2436 -> 2713) with an identical parsed AST — no production code, no assertion changes. Cap set to 2985 rather than the exact 2713 on the operator's instruction (2026-09-02): ~10% headroom so routine additions to this suite do not re-trip the gate on formatting alone. Previous cap 2437. This is a deliberate exception to the down-only ratchet for one reformatted test file; every other entry keeps the #12411 tightening.", @@ -412,7 +413,7 @@ "open-sse/executors/codex.ts": 1499, "open-sse/executors/cursor.ts": 1759, "open-sse/executors/muse-spark-web.ts": 1405, - "open-sse/handlers/chatCore.ts": 5976, + "open-sse/handlers/chatCore.ts": 5981, "open-sse/handlers/imageGeneration.ts": 3259, "open-sse/handlers/search.ts": 1789, "open-sse/mcp-server/schemas/tools.ts": 1621, @@ -629,5 +630,6 @@ "_rebaseline_2026_06_30_v3842_release_chatgptweb_compression": "v3.8.42 cycle-close file-size reconciliation (DRIFT measured OK on each PR's base, stacked above frozen at the merge tip; fast-path PR->release/** does not run check:file-size). (1) open-sse/executors/chatgpt-web.ts 2870->3206 (+336 = #5531 portable SHA3-512 sentinel-PoW wiring with the native-vs-fallback digest path + #5536 GPT-5.5 Pro handoff branch; the pure Keccak-f[1600] fallback itself already lives in the separate leaf open-sse/utils/sha3-512.ts — the executor growth is the cohesive call-site/handoff logic, not extractable without hiding the sentinel chokepoint). (2) tests/unit/chatgpt-web.test.ts 2855->3159 (+304 = #5536 GPT-5.5 Pro handoff coverage; pair-file with its executor). (3) open-sse/services/compression/strategySelector.ts 997->1022 (+25 = #5527 T02 honest default-on pipeline inflation guard wiring at the existing finalizeStackedResult choke). All cohesive at existing chokepoints; covered by tests/unit/chatgpt-web-sha3-boringssl-5531.test.ts, chatgpt-web.test.ts (GPT-5.5 Pro), compression-pipeline-inflation-guard.test.ts.", "open-sse/executors/chatgpt-web.ts": "3241", "_rebaseline_2026_08_30_11771_vercel_gateway_passthrough": "PR #11771 adds passthroughModels: true (1 line) to the Vercel AI Gateway registry entry — no split available, single-line provider-config addition.", - "_relax_velocity_2026_08_30": "127 frozen line caps and cap/testCap raised by 20% (velocity phase; see quality-baseline.json _policy)." + "_relax_velocity_2026_08_30": "127 frozen line caps and cap/testCap raised by 20% (velocity phase; see quality-baseline.json _policy).", + "_rebaseline_2026_09_02_12325_merge_v3851": "Merge of release/v3.8.51 into #12325. Both sides grew chatCore.ts at the same chokepoint: #12239 took it 5946->5976 upstream, and this PR adds its +9 non-Codex 429 branch on top. check-file-size.mjs counts split(\"\\\\n\").length (trailing-newline empty element), so the merged file is 5981. The cap is the merged LOC, not either side alone; no other entry moves." } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index a50df1069b..65a986d186 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -384,6 +384,7 @@ import { sanitizeOpenAITool } from "../services/toolSchemaSanitizer.ts"; import { isCompactResponsesEndpoint } from "../executors/codex.ts"; import { persistCodexChildQuotaResponse } from "../services/codexAccount/index.ts"; import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts"; +import { invalidateGenericQuotaCacheOnStatus } from "../services/genericQuotaFetcher.ts"; import { translateNonStreamingResponse } from "./responseTranslator.ts"; import { extractToolSchemaMap } from "../translator/response/openai-responses/toolSchemas.ts"; import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts"; @@ -3239,6 +3240,14 @@ export async function handleChatCore({ const errMessage = err instanceof Error ? err.message : String(err); log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`); } + } else if (attemptConnectionId && res.response.status === 429) { + // Dropped generic quota cache after 429 + invalidateGenericQuotaCacheOnStatus({ + provider, + connectionId: String(attemptConnectionId), + status: res.response.status, + isolateProbe: await shouldIsolateProbeFailures(), + }); } // Track Gemini RPM + RPD request counts for 429 classification diff --git a/open-sse/services/genericQuotaFetcher.ts b/open-sse/services/genericQuotaFetcher.ts index 10d58d4b37..81419d5a6b 100644 --- a/open-sse/services/genericQuotaFetcher.ts +++ b/open-sse/services/genericQuotaFetcher.ts @@ -25,10 +25,53 @@ import { type QuotaInfo, } from "./quotaPreflight.ts"; +type UsageFetcher = ( + connection: Parameters[0], + options?: { forceRefresh?: boolean } +) => Promise; + +let usageFetcherOverride: UsageFetcher | null = null; + // 60s — matches Codex's TTL. Long enough to avoid hammering upstream usage // endpoints on every routing decision, short enough that a near-exhausted // account is skipped within one minute of crossing its threshold. const CACHE_TTL_MS = 60_000; +/** Drop unused force-refresh flags once inner provider caches (60s–5min) have expired. */ +const PENDING_FORCE_REFRESH_TTL_MS = CACHE_TTL_MS * 5; +/** key → Date.now() when invalidate asked the next fetch to force-refresh. */ +const pendingForceRefresh = new Map(); +/** key → last convert-null / throw while force-refresh was pending. */ +const pendingForceRefreshMiss = new Map(); + +/** Test-only: inject the usage dispatcher; pass null to restore. */ +export function __setGenericUsageFetcherForTests(fetcher: UsageFetcher | null): void { + usageFetcherOverride = fetcher; +} + +/** Test-only: backdate a pending force-refresh so TTL expiry is unit-testable. */ +export function __agePendingForceRefreshForTests( + provider: string, + connectionId: string, + ageMs: number +): void { + pendingForceRefresh.set(cacheKey(provider, connectionId), Date.now() - ageMs); +} + +/** Test-only: backdate a convert-null miss so the 60s hammer-guard is unit-testable. */ +export function __agePendingForceRefreshMissForTests( + provider: string, + connectionId: string, + ageMs: number +): void { + pendingForceRefreshMiss.set(cacheKey(provider, connectionId), Date.now() - ageMs); +} + +/** Test-only: drop all wrapper/flag maps so tests cannot leak across ids. */ +export function __resetGenericQuotaFetcherForTests(): void { + cache.clear(); + pendingForceRefresh.clear(); + pendingForceRefreshMiss.clear(); +} interface CacheEntry { quota: QuotaInfo; @@ -38,15 +81,72 @@ interface CacheEntry { const cache = new Map(); function cacheKey(provider: string, connectionId: string): string { - return `${provider}::${connectionId}`; + return `${provider.trim()}::${connectionId.trim()}`; } -// Auto-cleanup stale entries — same shape as codexQuotaFetcher. +function dropExpiredPendingForceRefresh(key: string, now: number): boolean { + const stampedAt = pendingForceRefresh.get(key); + if (stampedAt === undefined) return true; + if (now - stampedAt > PENDING_FORCE_REFRESH_TTL_MS) { + pendingForceRefresh.delete(key); + pendingForceRefreshMiss.delete(key); + return true; + } + return false; +} + +// Lazy expiry on read — same as the provider breaker. Name stays `is*` because +// callers only need a boolean; the map is not a public API. +function isPendingForceRefresh(key: string, now: number = Date.now()): boolean { + if (dropExpiredPendingForceRefresh(key, now)) return false; + return pendingForceRefresh.has(key); +} + +function markPendingForceRefreshMiss(key: string): void { + if (isPendingForceRefresh(key)) pendingForceRefreshMiss.set(key, Date.now()); +} + +function cachedQuotaIfFresh( + key: string, + forceRefresh: boolean, + now: number +): QuotaInfo | null { + if (forceRefresh) return null; + const cached = cache.get(key); + if (cached && now - cached.fetchedAt < CACHE_TTL_MS) return cached.quota; + return null; +} + +function isForceRefreshMissCooling( + key: string, + forceRefresh: boolean, + now: number +): boolean { + if (!forceRefresh) return false; + const missedAt = pendingForceRefreshMiss.get(key); + return missedAt !== undefined && now - missedAt < CACHE_TTL_MS; +} + +/** True when a concurrent 429 re-stamped a still-live flag during fetchUsage. */ +function isConcurrentForceRefresh(key: string, refreshStamp: number | undefined): boolean { + const currentStamp = pendingForceRefresh.get(key); + if (currentStamp === refreshStamp) return false; + return ( + currentStamp !== undefined && + Date.now() - currentStamp <= PENDING_FORCE_REFRESH_TTL_MS + ); +} + +// 5min — same as Codex. Expiry is lazy on read (`isPendingForceRefresh`); +// this timer only reaps keys nobody fetches after the 5min TTL. const _cacheCleanup = setInterval(() => { const now = Date.now(); for (const [key, entry] of cache) { if (now - entry.fetchedAt > CACHE_TTL_MS * 5) cache.delete(key); } + for (const key of pendingForceRefresh.keys()) { + dropExpiredPendingForceRefresh(key, now); + } }, 5 * 60_000); if (typeof _cacheCleanup === "object" && "unref" in _cacheCleanup) { (_cacheCleanup as { unref?: () => void }).unref?.(); @@ -217,24 +317,47 @@ function normalizeQuotaWindows( export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection) => { if (!connection) return null; const conn = connection as ConnectionInputs; - const provider = typeof conn.provider === "string" ? conn.provider : null; + const provider = typeof conn.provider === "string" ? conn.provider.trim() : ""; if (!provider) return null; const key = cacheKey(provider, connectionId); - const cached = cache.get(key); - if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { - return cached.quota; - } + const now = Date.now(); + const forceRefresh = isPendingForceRefresh(key, now); + const hit = cachedQuotaIfFresh(key, forceRefresh, now); + if (hit) return hit; + // convert-null / throw keep the force-refresh flag (agy inner caches are + // still stale) but must not hammer those endpoints on every routing tick. + if (isForceRefreshMissCooling(key, forceRefresh, now)) return null; + + // Capture before await: a 429 during fetchUsage re-stamps this; writing + // the pre-429 snapshot would wipe that flag and recache stale quota. + const refreshStamp = pendingForceRefresh.get(key); let usage: unknown; try { - usage = await getUsageForProvider(conn as Parameters[0]); + const fetchUsage = usageFetcherOverride ?? getUsageForProvider; + usage = await fetchUsage(conn as Parameters[0], { + ...(forceRefresh ? { forceRefresh: true } : {}), + }); } catch { + markPendingForceRefreshMiss(key); return null; } const quota = convertUsageToQuotaInfo(usage); - if (!quota) return null; + if (!quota) { + markPendingForceRefreshMiss(key); + return null; + } + + // Concurrent 429 re-stamped a still-live flag — do not recache the + // pre-429 snapshot. A vanished or expired stamp is not a 429. + if (isConcurrentForceRefresh(key, refreshStamp)) { + return quota; + } + + pendingForceRefresh.delete(key); + pendingForceRefreshMiss.delete(key); // Refresh the static window catalog so the dashboard can render the right // modal inputs without waiting for the user to open the page. @@ -250,7 +373,33 @@ export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection) * fresh data instead of a 60s stale window. */ export function invalidateGenericQuotaCache(provider: string, connectionId: string): void { - cache.delete(cacheKey(provider, connectionId)); + const key = cacheKey(provider, connectionId); + cache.delete(key); + // Next fetch must bypass provider-inner usage caches (agy retrieveUserQuota / + // weekly are 60s–5min). Without this, dropping the 60s wrapper recaches stale. + // TTL matches those inner caches: after 5min the flag is a no-op. + pendingForceRefresh.set(key, Date.now()); + pendingForceRefreshMiss.delete(key); +} + +/** + * Drop the generic quota cache after an upstream 429, matching Codex's + * `invalidateCodexQuotaCache` on 429. Probe-origin failures must not mutate + * routing caches (#9817). + */ +export function invalidateGenericQuotaCacheOnStatus(args: { + provider: string | null | undefined; + connectionId: string | null | undefined; + status: number; + isolateProbe?: boolean; +}): boolean { + if (args.isolateProbe === true) return false; // undefined from callers that omit isolateProbe must still invalidate + if (args.status !== 429) return false; + const provider = typeof args.provider === "string" ? args.provider.trim() : ""; + const connectionId = typeof args.connectionId === "string" ? args.connectionId.trim() : ""; + if (!provider || !connectionId) return false; + invalidateGenericQuotaCache(provider, connectionId); + return true; } /** diff --git a/tests/unit/antigravity-429-quota-cooldown.test.ts b/tests/unit/antigravity-429-quota-cooldown.test.ts index 0191391d8a..eccdc8942d 100644 --- a/tests/unit/antigravity-429-quota-cooldown.test.ts +++ b/tests/unit/antigravity-429-quota-cooldown.test.ts @@ -169,6 +169,11 @@ test("direct Antigravity has one downstream model-lock owner and clamps body pro /accountSemaphoreKey && !deferAntigravityQuotaStateToCaller/, "chatCore must not apply a prose-derived Antigravity semaphore TTL" ); + assert.match( + chatCoreSource, + /Dropped generic quota cache after 429/, + "non-Codex 429 must leave a QUOTA debug breadcrumb" + ); assert.match( chatCoreSource, /if \(deferAntigravityQuotaStateToCaller\)[\s\S]{0,2000}else if \(kimiRateLimitResetAt\)/ diff --git a/tests/unit/generic-quota-fetcher.test.ts b/tests/unit/generic-quota-fetcher.test.ts index d8edbd9dae..6edce4894f 100644 --- a/tests/unit/generic-quota-fetcher.test.ts +++ b/tests/unit/generic-quota-fetcher.test.ts @@ -4,9 +4,36 @@ import assert from "node:assert/strict"; const genericModule = await import("../../open-sse/services/genericQuotaFetcher.ts"); const preflightModule = await import("../../open-sse/services/quotaPreflight.ts"); -const { convertUsageToQuotaInfo, registerGenericQuotaFetchers } = genericModule; +const { + convertUsageToQuotaInfo, + fetchGenericQuota, + invalidateGenericQuotaCache, + invalidateGenericQuotaCacheOnStatus, + registerGenericQuotaFetchers, + __setGenericUsageFetcherForTests, + __agePendingForceRefreshForTests, + __agePendingForceRefreshMissForTests, + __resetGenericQuotaFetcherForTests, +} = genericModule; const { getQuotaFetcher } = preflightModule; +function usageShape(remainingPercentage: number) { + return { + quotas: { + "gemini-3-flash": { + remainingPercentage, + fractionReported: true, + resetAt: "2026-09-01T20:00:00Z", + }, + gemini_models_weekly: { + remainingPercentage, + fractionReported: true, + resetAt: "2026-09-07T00:00:00Z", + }, + }, + }; +} + test("convertUsageToQuotaInfo returns null on null/undefined input", () => { assert.equal(convertUsageToQuotaInfo(null), null); assert.equal(convertUsageToQuotaInfo(undefined), null); @@ -114,3 +141,250 @@ test("registerGenericQuotaFetchers registers Claude, GLM, and OpenCode Go via th // which would couple this test to chat.ts startup wiring. The skip list // semantics are exercised by the source code review. }); + +test.afterEach(() => { + __setGenericUsageFetcherForTests(null); + __resetGenericQuotaFetcherForTests(); +}); + +test("fetchGenericQuota caches a hit inside the 60s window", async () => { + const connectionId = `agy-cache-${Date.now()}`; + const calls: Array<{ forceRefresh?: boolean }> = []; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + return usageShape(80); + }); + + const connection = { provider: "agy", id: connectionId }; + const first = await fetchGenericQuota(connectionId, connection); + const second = await fetchGenericQuota(connectionId, connection); + + assert.equal(calls.length, 1, "second fetch must reuse the generic cache"); + assert.equal(first?.percentUsed, 0.2); + assert.deepEqual(second, first); + invalidateGenericQuotaCache("agy", connectionId); +}); + +test("invalidateGenericQuotaCache makes the next fetch bypass provider-inner usage caches", async () => { + const connectionId = `agy-invalidate-${Date.now()}`; + const calls: Array<{ forceRefresh?: boolean }> = []; + let remaining = 80; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + return usageShape(remaining); + }); + + const connection = { provider: "agy", id: connectionId }; + const first = await fetchGenericQuota(connectionId, connection); + assert.equal(first?.percentUsed, 0.2); + assert.equal(calls[0]?.forceRefresh, undefined); + + remaining = 0; + invalidateGenericQuotaCache("agy", connectionId); + const second = await fetchGenericQuota(connectionId, connection); + + assert.equal(calls.length, 2, "invalidate must drop the 60s generic cache"); + assert.equal( + calls[1]?.forceRefresh, + true, + "agy retrieveUserQuota / weekly caches are 60s–5min; invalidate must force-refresh or the recache is stale" + ); + assert.equal(second?.percentUsed, 1); + invalidateGenericQuotaCache("agy", connectionId); +}); + +test("invalidateGenericQuotaCacheOnStatus drops cache on 429 and ignores 200", async () => { + const connectionId = `agy-429-${Date.now()}`; + const calls: Array<{ forceRefresh?: boolean }> = []; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + return usageShape(50); + }); + + const connection = { provider: "agy", id: connectionId }; + await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 1); + + invalidateGenericQuotaCacheOnStatus({ + provider: "agy", + connectionId, + status: 200, + isolateProbe: false, + }); + await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 1, "200 must not drop the generic quota cache"); + + const dropped429 = invalidateGenericQuotaCacheOnStatus({ + provider: "agy", + connectionId, + status: 429, + isolateProbe: false, + }); + assert.equal(dropped429, true); + await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 2, "429 must drop the generic quota cache"); + assert.equal(calls[1]?.forceRefresh, true); + + invalidateGenericQuotaCacheOnStatus({ + provider: "agy", + connectionId, + status: 429, + isolateProbe: true, + }); + await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 2, "probe-origin 429 must not touch routing caches"); + + assert.doesNotThrow(() => + invalidateGenericQuotaCacheOnStatus({ + provider: "agy", + connectionId: null, + status: 429, + isolateProbe: false, + }) + ); + invalidateGenericQuotaCache("agy", connectionId); +}); + +test("invalidateGenericQuotaCacheOnStatus trims to the same key fetchGenericQuota uses", async () => { + const connectionId = `agy-trim-${Date.now()}`; + const calls: Array<{ forceRefresh?: boolean }> = []; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + return usageShape(40); + }); + + const connection = { provider: "agy", id: connectionId }; + await fetchGenericQuota(` ${connectionId} `, connection); + assert.equal(calls.length, 1); + + const dropped = invalidateGenericQuotaCacheOnStatus({ + provider: " agy ", + connectionId: ` ${connectionId} `, + status: 429, + isolateProbe: false, + }); + assert.equal(dropped, true); + + await fetchGenericQuota(connectionId, { provider: "agy", id: connectionId }); + assert.equal(calls.length, 2, "padded 429 key must drop the unpadded wrapper cache"); + assert.equal(calls[1]?.forceRefresh, true); + invalidateGenericQuotaCache("agy", connectionId); +}); + +test("convert-null after invalidate keeps forceRefresh until a measurable quota recaches", async () => { + const connectionId = `agy-null-${Date.now()}`; + const calls: Array<{ forceRefresh?: boolean }> = []; + let n = 0; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + n += 1; + if (n === 1) return usageShape(80); + if (n === 2) return { message: "auth expired" }; + return usageShape(10); + }); + + const connection = { provider: "agy", id: connectionId }; + await fetchGenericQuota(connectionId, connection); + invalidateGenericQuotaCache("agy", connectionId); + + const second = await fetchGenericQuota(connectionId, connection); + assert.equal(second, null); + assert.equal(calls[1]?.forceRefresh, true); + + await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 2, "convert-null must not hammer usage inside 60s"); + + __agePendingForceRefreshMissForTests("agy", connectionId, 60_000 + 1); + const fourth = await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 3); + assert.equal(calls[2]?.forceRefresh, true, "convert-null must not drop the force-refresh flag"); + assert.equal(fourth?.percentUsed, 0.9); + invalidateGenericQuotaCache("agy", connectionId); +}); + +test("in-flight fetch must not drop a concurrent 429 force-refresh", async () => { + const connectionId = `agy-race-${Date.now()}`; + let release: (value?: unknown) => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const calls: Array<{ forceRefresh?: boolean }> = []; + let n = 0; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + n += 1; + if (n === 1) { + await gate; + return usageShape(80); + } + return usageShape(10); + }); + + const connection = { provider: "agy", id: connectionId }; + const inflight = fetchGenericQuota(connectionId, connection); + await Promise.resolve(); + invalidateGenericQuotaCache("agy", connectionId); + release(); + const first = await inflight; + assert.equal(first?.percentUsed, 0.2); + + const second = await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 2, "concurrent 429 must not let the in-flight recache wipe force-refresh"); + assert.equal(calls[1]?.forceRefresh, true); + assert.equal(second?.percentUsed, 0.9); + invalidateGenericQuotaCache("agy", connectionId); +}); + +test("expired pending force-refresh does not bypass the 60s wrapper cache", async () => { + const connectionId = `agy-expire-${Date.now()}`; + const calls: Array<{ forceRefresh?: boolean }> = []; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + return usageShape(80); + }); + + const connection = { provider: "agy", id: connectionId }; + await fetchGenericQuota(connectionId, connection); + invalidateGenericQuotaCache("agy", connectionId); + __agePendingForceRefreshForTests("agy", connectionId, 5 * 60_000 + 1); + + await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 2, "wrapper cache was dropped; fetch still happens"); + assert.equal( + calls[1]?.forceRefresh, + undefined, + "expired force-refresh must not pass forceRefresh after inner caches have aged out" + ); + invalidateGenericQuotaCache("agy", connectionId); +}); + +test("stamp expiry during in-flight fetch still writes the wrapper cache", async () => { + const connectionId = `agy-stamp-expire-${Date.now()}`; + let release: (value?: unknown) => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const calls: Array<{ forceRefresh?: boolean }> = []; + let n = 0; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + n += 1; + if (n === 1) return usageShape(50); + await gate; + return usageShape(80); + }); + + const connection = { provider: "agy", id: connectionId }; + await fetchGenericQuota(connectionId, connection); + invalidateGenericQuotaCache("agy", connectionId); + + const inflight = fetchGenericQuota(connectionId, connection); + await Promise.resolve(); + __agePendingForceRefreshForTests("agy", connectionId, 5 * 60_000 + 1); + release(); + await inflight; + + await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 2, "expired stamp during await is not a 429; cache the result"); + invalidateGenericQuotaCache("agy", connectionId); +}); From c2d2b0ac1454b984c369ca4396608e77cb0aebde Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Thu, 3 Sep 2026 11:39:03 -0400 Subject: [PATCH 28/47] feat(providers): surface CSV import row errors and ship a template (#12504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca. O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. --- .../features/12071-csv-import-errors.md | 1 + docs/guides/USER_GUIDE.md | 2 + docs/providers/CSV-IMPORT.md | 43 +++++ docs/providers/meta.json | 3 +- .../ImportProvidersFromFileModal.tsx | 40 ++++- .../components/providerImportFeedback.ts | 165 ++++++++++++++++++ .../components/useImportProvidersFromFile.ts | 28 +-- src/i18n/messages/en.json | 3 + src/i18n/messages/pt-BR.json | 3 + src/i18n/messages/vi.json | 3 + .../provider-import-feedback-12071.test.ts | 143 +++++++++++++++ 11 files changed, 418 insertions(+), 16 deletions(-) create mode 100644 changelog.d/features/12071-csv-import-errors.md create mode 100644 docs/providers/CSV-IMPORT.md create mode 100644 src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts create mode 100644 tests/unit/provider-import-feedback-12071.test.ts diff --git a/changelog.d/features/12071-csv-import-errors.md b/changelog.d/features/12071-csv-import-errors.md new file mode 100644 index 0000000000..096eed4ef6 --- /dev/null +++ b/changelog.d/features/12071-csv-import-errors.md @@ -0,0 +1 @@ +- **feat(providers):** import-from-file modal shows per-row API errors and ships a downloadable CSV template ([#12071](https://github.com/diegosouzapw/OmniRoute/issues/12071)) diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 72acc05bfc..ceed44c426 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -122,6 +122,8 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... ## 📖 Provider Setup +To bulk-add API-key connections from a CSV or JSON file, use **Dashboard → Providers → Import from file**. Columns are positional (`provider,name,apiKey,baseUrl,priority`); `provider` must already exist as a managed provider or a compatible node. See [Import providers from a CSV or JSON file](../providers/CSV-IMPORT.md). + ### 🔐 Subscription Providers #### Claude Code (Pro/Max) diff --git a/docs/providers/CSV-IMPORT.md b/docs/providers/CSV-IMPORT.md new file mode 100644 index 0000000000..4f701a16a2 --- /dev/null +++ b/docs/providers/CSV-IMPORT.md @@ -0,0 +1,43 @@ +--- +title: "Import providers from a CSV or JSON file" +--- + +# Import providers from a CSV or JSON file + +Dashboard → Providers → **Import from file** creates API-key connections from a CSV or JSON list. Each row can target a different provider. Partial failure is the contract: valid rows still import when others fail, and the modal lists why the failed rows were rejected. + +This import does **not** create new OpenAI/Anthropic-compatible endpoint nodes. Create those first (Dashboard → Providers → Add OpenAI-Compatible, or `omniroute nodes add`), then import rows whose `provider` column is that node's id. A per-row `baseUrl` can still override the node's URL. + +## CSV (positional) + +Column names are cosmetic. The parser splits each row and destructures by index: + +| Index | Field | Required | Notes | +| ----- | ----- | -------- | ----- | +| 0 | `provider` | yes | Existing managed provider id (`openai`, `anthropic`, …) **or** an already-registered OpenAI/Anthropic-compatible **node** id | +| 1 | `name` | yes | Connection display name | +| 2 | `apiKey` | yes | API key | +| 3 | `baseUrl` | no | Per-row URL override | +| 4 | `priority` | no | Integer 1–100 | + +A first line whose first column is the literal word `provider` (any case) is skipped as a header. Blank lines and `#` comments are skipped. + +Download a starter file from the import modal (**Download CSV template**). Example: + +```csv +# OmniRoute provider import (positional columns) +provider,name,apiKey,baseUrl,priority +openai,Prod OpenAI,sk-your-openai-key,,1 +``` + +A made-up id such as `openai-compatible-chat-001` is not a node. The API returns `Unknown or unsupported provider` for that row; the modal shows it next to the row name. + +## JSON + +A JSON array of objects with the same fields (`provider`, `name`, `apiKey`, `baseUrl?`, `priority?`). Unlike CSV, JSON keys are named. + +```json +[ + { "provider": "openai", "name": "Prod OpenAI", "apiKey": "sk-your-openai-key", "priority": 1 } +] +``` diff --git a/docs/providers/meta.json b/docs/providers/meta.json index fa6485dd57..b5eca33685 100644 --- a/docs/providers/meta.json +++ b/docs/providers/meta.json @@ -8,6 +8,7 @@ "AGENTROUTER", "ZED-DOCKER", "CURSOR-DOCKER", - "CURSOR-API-KEY-AND-CLI" + "CURSOR-API-KEY-AND-CLI", + "CSV-IMPORT" ] } diff --git a/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx b/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx index 0363f205d8..b9f9417b50 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx @@ -4,6 +4,12 @@ import { useTranslations } from "next-intl"; import { Button, Modal } from "@/shared/components"; import type { ParsedProviderImportEntry, ProviderImportParseError } from "./parseProviderImportFile"; import { useImportProvidersFromFile } from "./useImportProvidersFromFile"; +import { + downloadProviderImportCsvTemplate, + formatImportErrorLine, + visibleImportErrors, + type ImportResult, +} from "./providerImportFeedback"; interface ImportProvidersFromFileModalProps { isOpen: boolean; @@ -122,6 +128,30 @@ function FilePickerRow({ fileInputRef, fileName, onFile, t }: FilePickerRowProps ); } +function ImportResultPanel({ result, t }: { result: ImportResult; t: Translator }) { + const { shown, extra } = visibleImportErrors(result.errors); + const failed = result.failed > 0 || shown.length > 0; + return ( +
+ {t("importFromFileResult", { success: result.success, failed: result.failed })} + {shown.length > 0 && ( +
    + {shown.map((err, i) => ( +
  • {formatImportErrorLine(err)}
  • + ))} + {extra > 0 &&
  • {t("importFromFileMoreErrors", { count: extra })}
  • } +
+ )} +
+ ); +} + /** * Wizard step: upload a CSV/JSON file listing MULTIPLE, possibly different providers, * pick which parsed rows to actually import, then submit them in one batch (#6836). @@ -141,18 +171,18 @@ export function ImportProvidersFromFileModal({ s.handleClose(onClose)} title={t("importFromFileTitle")} maxWidth="xl">

{t("importFromFileDescription")}

+

{t("importFromFileSchemaHint")}

- {s.result && ( -
- {t("importFromFileResult", { success: s.result.success, failed: s.result.failed })} -
- )} + {s.result && }
+ diff --git a/src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts b/src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts new file mode 100644 index 0000000000..4f1564051a --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts @@ -0,0 +1,165 @@ +/** + * #12071 — import-modal feedback helpers. + * + * POST /api/providers/import already returns per-row `{index,name,provider,message}`. + * The modal used to keep only success/failed/total and drop `errors` on the floor. + * These helpers stay a pure, dependency-free module so the hook can stay under the + * LOC ratchet and the same formatter can be unit-tested without React. + */ + +export type ImportRowError = { + index?: number; + name?: string; + provider?: string; + message: string; +}; + +export type ImportResult = { + success: number; + failed: number; + total: number; + errors: ImportRowError[]; +}; + +const VISIBLE_ERROR_CAP = 10; + +function asFiniteNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function asRowError(value: unknown): ImportRowError | null { + if (!value || typeof value !== "object") return null; + const row = value as Record; + if (typeof row.message !== "string" || !row.message.trim()) return null; + return { + ...(typeof row.index === "number" && Number.isFinite(row.index) ? { index: row.index } : {}), + ...(typeof row.name === "string" && row.name.trim() ? { name: row.name.trim() } : {}), + ...(typeof row.provider === "string" && row.provider.trim() ? { provider: row.provider.trim() } : {}), + message: row.message.trim(), + }; +} + +/** Keep counts plus a sanitized `errors` array. A missing/non-array field becomes []. */ +export function normalizeImportResponse(data: unknown): ImportResult { + const body = data && typeof data === "object" ? (data as Record) : {}; + const rawErrors = Array.isArray(body.errors) ? body.errors : []; + return { + success: asFiniteNumber(body.success), + failed: asFiniteNumber(body.failed), + total: asFiniteNumber(body.total), + errors: rawErrors.map(asRowError).filter((row): row is ImportRowError => row !== null), + }; +} + +export type ImportHttpOutcome = { + result: ImportResult; + shouldRefresh: boolean; +}; + +function httpFailureResult(status: number, data: unknown, fallback: ImportResult): ImportResult { + if (fallback.errors.length > 0) { + return { ...fallback, success: 0 }; + } + const body = data && typeof data === "object" ? (data as Record) : {}; + const detail = typeof body.error === "string" ? body.error.trim() : ""; + const message = detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`; + return { + success: 0, + failed: Math.max(1, fallback.failed), + total: Math.max(1, fallback.total), + errors: [{ message }], + }; +} + +/** + * Map an import HTTP response onto the modal result. + * Non-ok statuses still populate `errors`. Refresh is a boolean so the hook + * can await `onImported` outside this function (a throw there must not + * overwrite a successful import result). + */ +export function applyImportHttpOutcome( + res: { ok: boolean; status: number }, + data: unknown +): ImportHttpOutcome { + const normalized = normalizeImportResponse(data); + if (!res.ok) { + return { result: httpFailureResult(res.status, data, normalized), shouldRefresh: false }; + } + return { result: normalized, shouldRefresh: normalized.success > 0 }; +} + +/** Parse the import response body. Non-JSON becomes `{ ok: false, data: { error } }`. */ +export async function readImportResponse(res: Response): Promise<{ + ok: boolean; + status: number; + data: unknown; +}> { + try { + return { ok: res.ok, status: res.status, data: await res.json() }; + } catch { + return { ok: false, status: res.status, data: { error: "Invalid JSON body" } }; + } +} + +export function networkImportFailure(err: unknown): ImportResult { + return { + success: 0, + failed: 1, + total: 1, + errors: [{ message: err instanceof Error ? err.message : "Import request failed" }], + }; +} + +/** First 10 rows plus the leftover count — same cap as AddApiKeyModal bulk import. */ +export function visibleImportErrors(errors: ImportRowError[]): { + shown: ImportRowError[]; + extra: number; +} { + return { + shown: errors.slice(0, VISIBLE_ERROR_CAP), + extra: Math.max(0, errors.length - VISIBLE_ERROR_CAP), + }; +} + +/** One line for the modal list: name, else provider, else 1-based row index. */ +export function formatImportErrorLine(err: ImportRowError): string { + const label = + (typeof err.name === "string" && err.name.trim()) || + (typeof err.provider === "string" && err.provider.trim()) || + (typeof err.index === "number" && Number.isFinite(err.index) ? `row ${err.index + 1}` : "row"); + return `${label}: ${err.message}`; +} + +/** + * Positional CSV sample. Column 0 must be an *existing* managed provider id + * or an already-registered OpenAI/Anthropic-compatible node id — this import + * does not create new endpoint nodes. Header names are cosmetic; the parser + * destructures by index (`provider,name,apiKey,baseUrl,priority`). + */ +export const PROVIDER_IMPORT_CSV_TEMPLATE = `# OmniRoute provider import (positional columns) +# Columns: provider, name, apiKey, baseUrl (optional), priority (optional, 1-100) +# The provider column must be an existing managed provider id (openai, anthropic, …) +# or an already-registered OpenAI/Anthropic-compatible node id. +# This import does not create new endpoint nodes. Add those first (Dashboard → Providers → Add OpenAI-Compatible). +provider,name,apiKey,baseUrl,priority +openai,Prod OpenAI,sk-your-openai-key,,1 +`; + +export function downloadTextFile(content: string, filename: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + try { + document.body.appendChild(link); + link.click(); + } finally { + link.remove(); + URL.revokeObjectURL(url); + } +} + +export function downloadProviderImportCsvTemplate(): void { + downloadTextFile(PROVIDER_IMPORT_CSV_TEMPLATE, "omniroute-provider-import-template.csv", "text/csv"); +} diff --git a/src/app/(dashboard)/dashboard/providers/components/useImportProvidersFromFile.ts b/src/app/(dashboard)/dashboard/providers/components/useImportProvidersFromFile.ts index ecf50e5bcb..a09f00e0c4 100644 --- a/src/app/(dashboard)/dashboard/providers/components/useImportProvidersFromFile.ts +++ b/src/app/(dashboard)/dashboard/providers/components/useImportProvidersFromFile.ts @@ -4,13 +4,17 @@ import { type ParsedProviderImportEntry, type ProviderImportParseError, } from "./parseProviderImportFile"; +import { + applyImportHttpOutcome, + networkImportFailure, + readImportResponse, + type ImportResult, +} from "./providerImportFeedback"; -export type ImportResult = { success: number; failed: number; total: number }; +export type { ImportResult }; /** - * All state + handlers for `ImportProvidersFromFileModal`, split into a hook purely - * to keep the component's own function under the repo's max-lines-per-function ratchet - * (#6836). Behavior is unchanged — this is a pure extraction, not a refactor. + * State + handlers for ImportProvidersFromFileModal (#6836/#12071). */ export function useImportProvidersFromFile(onImported: () => Promise) { const fileInputRef = useRef(null); @@ -66,15 +70,19 @@ export function useImportProvidersFromFile(onImported: () => Promise) { setImporting(true); try { const res = await fetch("/api/providers/import", { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ entries: toImport }), }); - const data = await res.json().catch(() => ({})); - if (res.ok) { - setResult({ success: data.success ?? 0, failed: data.failed ?? 0, total: data.total ?? 0 }); - await onImported(); + const parsed = await readImportResponse(res); + const outcome = applyImportHttpOutcome(parsed, parsed.data); + setResult(outcome.result); + if (outcome.shouldRefresh) { + try { + await onImported(); + } catch { /* refresh failure must not replace the import result */ } } + } catch (err) { + setResult(networkImportFailure(err)); } finally { setImporting(false); } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 292d76c86d..9424b64ed7 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5067,6 +5067,9 @@ "importFromFileImporting": "Importing…", "importFromFileImport": "Import {count} providers", "importFromFileResult": "Imported {success} providers ({failed} failed)", + "importFromFileDownloadTemplate": "Download CSV template", + "importFromFileMoreErrors": "+{count} more", + "importFromFileSchemaHint": "CSV columns are positional: provider, name, apiKey, baseUrl (optional), priority (optional). The provider column must be an existing managed provider id or an already-registered OpenAI/Anthropic-compatible node id — this import does not create new endpoint nodes.", "adaptaTutorial": { "title": "How to connect Adapta Web", "introPrefix": "Adapta authenticates through Clerk. The token", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 7c4d1eb12c..5199f22e2c 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -5068,6 +5068,9 @@ "importFromFileImporting": "Importando…", "importFromFileImport": "Importar {count} provedores", "importFromFileResult": "{success} provedores importados ({failed} falharam)", + "importFromFileDownloadTemplate": "Baixar modelo CSV", + "importFromFileMoreErrors": "+{count} mais", + "importFromFileSchemaHint": "As colunas CSV são posicionais: provider, name, apiKey, baseUrl (opcional), priority (opcional). A coluna provider deve ser o id de um provedor gerenciado existente ou o id de um nó compatível com OpenAI/Anthropic já registrado — esta importação não cria novos nós de endpoint.", "adaptaTutorial": { "title": "Como conectar o Adapta Web", "introPrefix": "Adapta autentica através do Clerk. O token", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 61287b0446..874cab83b8 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -5068,6 +5068,9 @@ "importFromFileImporting": "Đang nhập…", "importFromFileImport": "Nhập {count} nhà cung cấp", "importFromFileResult": "Đã nhập {success} nhà cung cấp ({failed} không thành công)", + "importFromFileDownloadTemplate": "Tải mẫu CSV", + "importFromFileMoreErrors": "+{count} nữa", + "importFromFileSchemaHint": "Các cột CSV theo vị trí: provider, name, apiKey, baseUrl (tùy chọn), priority (tùy chọn). Cột provider phải là id nhà cung cấp được quản lý hiện có hoặc id nút tương thích OpenAI/Anthropic đã đăng ký — quá trình nhập này không tạo nút endpoint mới.", "adaptaTutorial": { "title": "How to connect Adapta Web", "introPrefix": "Adapta authenticates through Clerk. The token", diff --git a/tests/unit/provider-import-feedback-12071.test.ts b/tests/unit/provider-import-feedback-12071.test.ts new file mode 100644 index 0000000000..8db247a84e --- /dev/null +++ b/tests/unit/provider-import-feedback-12071.test.ts @@ -0,0 +1,143 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const feedback = await import( + "../../src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts" +); +const { parseProviderImportFile } = await import( + "../../src/app/(dashboard)/dashboard/providers/components/parseProviderImportFile.ts" +); + +test("#12071 normalizeImportResponse keeps the per-row errors array", () => { + const result = feedback.normalizeImportResponse({ + success: 1, + failed: 2, + total: 3, + errors: [ + { index: 1, name: "srv-107", provider: "openai-compatible-chat-001", message: "Unknown or unsupported provider" }, + { index: 2, name: "srv-135", provider: "openai", message: "Provider node not found" }, + ], + }); + assert.equal(result.success, 1); + assert.equal(result.failed, 2); + assert.equal(result.total, 3); + assert.equal(result.errors.length, 2); + assert.equal(result.errors[0].message, "Unknown or unsupported provider"); + assert.equal(result.errors[1].name, "srv-135"); +}); + +test("#12071 normalizeImportResponse treats a missing errors field as [] (today's silent drop)", () => { + const result = feedback.normalizeImportResponse({ success: 0, failed: 3, total: 3 }); + assert.deepEqual(result.errors, []); + assert.equal(result.failed, 3); +}); + +test("#12071 normalizeImportResponse ignores a non-array errors field", () => { + const result = feedback.normalizeImportResponse({ success: 0, failed: 1, total: 1, errors: "boom" }); + assert.deepEqual(result.errors, []); +}); + +test("#12071 visibleImportErrors caps at 10 and reports the remainder", () => { + const errors = Array.from({ length: 12 }, (_, i) => ({ message: `row ${i}` })); + const { shown, extra } = feedback.visibleImportErrors(errors); + assert.equal(shown.length, 10); + assert.equal(extra, 2); + assert.equal(shown[0].message, "row 0"); +}); + +test("#12071 formatImportErrorLine prefers name, then provider, then 1-based row", () => { + assert.equal( + feedback.formatImportErrorLine({ name: "Grade-S-Node", message: "Unknown or unsupported provider" }), + "Grade-S-Node: Unknown or unsupported provider" + ); + assert.equal( + feedback.formatImportErrorLine({ provider: "openai", message: "Provider node not found" }), + "openai: Provider node not found" + ); + assert.equal(feedback.formatImportErrorLine({ index: 0, message: "failed" }), "row 1: failed"); +}); + +test("#12071 CSV template is positional and parses to one openai row", () => { + const parsed = parseProviderImportFile(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, "csv"); + assert.equal(parsed.errors.length, 0); + assert.equal(parsed.entries.length, 1); + assert.equal(parsed.entries[0].provider, "openai"); + assert.equal(parsed.entries[0].name, "Prod OpenAI"); + assert.equal(parsed.entries[0].apiKey, "sk-your-openai-key"); + assert.equal(parsed.entries[0].priority, 1); +}); + +test("#12071 CSV template comments document that provider must already exist", () => { + assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /existing managed provider/i); + assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /does not create new endpoint nodes/i); + assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /positional/i); +}); + +test("#12071 asRowError trims leading/trailing whitespace on message", () => { + const result = feedback.normalizeImportResponse({ + success: 0, + failed: 1, + total: 1, + errors: [{ name: "srv-107", message: " Unknown or unsupported provider " }], + }); + assert.equal(result.errors.length, 1); + assert.equal(result.errors[0].message, "Unknown or unsupported provider"); +}); + +test("#12071 applyImportHttpOutcome on !ok zeros success even if the body claimed some", () => { + const outcome = feedback.applyImportHttpOutcome( + { ok: false, status: 500 }, + { + success: 5, + failed: 0, + total: 5, + errors: [{ name: "a", message: "Unknown or unsupported provider" }], + } + ); + assert.equal(outcome.shouldRefresh, false); + assert.equal(outcome.result.success, 0); + assert.equal(outcome.result.errors.length, 1); +}); + +test("#12071 applyImportHttpOutcome surfaces non-ok HTTP without calling onImported", () => { + const outcome = feedback.applyImportHttpOutcome( + { ok: false, status: 400 }, + { error: "Invalid JSON body" } + ); + assert.equal(outcome.shouldRefresh, false); + assert.equal(outcome.result.success, 0); + assert.equal(outcome.result.failed, 1); + assert.equal(outcome.result.errors.length, 1); + assert.match(outcome.result.errors[0].message, /HTTP 400/); +}); + +test("#12071 applyImportHttpOutcome on ok with success>0 requests refresh", () => { + const outcome = feedback.applyImportHttpOutcome( + { ok: true, status: 200 }, + { success: 2, failed: 1, total: 3, errors: [{ name: "bad", message: "Unknown or unsupported provider" }] } + ); + assert.equal(outcome.shouldRefresh, true); + assert.equal(outcome.result.success, 2); + assert.equal(outcome.result.failed, 1); + assert.equal(outcome.result.errors[0].name, "bad"); +}); + +test("#12071 applyImportHttpOutcome on ok with success=0 still keeps errors and skips refresh", () => { + const outcome = feedback.applyImportHttpOutcome( + { ok: true, status: 200 }, + { success: 0, failed: 3, total: 3, errors: [{ name: "a", message: "Unknown or unsupported provider" }] } + ); + assert.equal(outcome.shouldRefresh, false); + assert.equal(outcome.result.success, 0); + assert.equal(outcome.result.errors.length, 1); +}); + +test("#12071 readImportResponse treats JSON parse failure as !ok with a body error", async () => { + const res = new Response("not-json", { status: 200, headers: { "Content-Type": "text/plain" } }); + const parsed = await feedback.readImportResponse(res); + assert.equal(parsed.ok, false); + assert.equal(parsed.status, 200); + const outcome = feedback.applyImportHttpOutcome(parsed, parsed.data); + assert.equal(outcome.shouldRefresh, false); + assert.match(outcome.result.errors[0].message, /Invalid JSON body/); +}); From 35caeb31f2b5f67d060c07eac087e37244590bf9 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Thu, 3 Sep 2026 11:39:27 -0400 Subject: [PATCH 29/47] feat(settings): persist headroomUrl for the Headroom proxy (#12487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca. O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. --- changelog.d/features/12306-headroom-url.md | 1 + .../dashboard/settings/advanced/page.tsx | 2 + .../settings/components/HeadroomProxyCard.tsx | 235 ++++++++++++++++++ src/i18n/messages/ar.json | 10 +- src/i18n/messages/az.json | 10 +- src/i18n/messages/bg.json | 10 +- src/i18n/messages/bn.json | 10 +- src/i18n/messages/cs.json | 10 +- src/i18n/messages/da.json | 10 +- src/i18n/messages/de.json | 10 +- src/i18n/messages/en.json | 8 + src/i18n/messages/es.json | 10 +- src/i18n/messages/fa.json | 10 +- src/i18n/messages/fi.json | 10 +- src/i18n/messages/fr.json | 10 +- src/i18n/messages/gu.json | 10 +- src/i18n/messages/he.json | 10 +- src/i18n/messages/hi.json | 10 +- src/i18n/messages/hu.json | 10 +- src/i18n/messages/id.json | 10 +- src/i18n/messages/it.json | 10 +- src/i18n/messages/ja.json | 10 +- src/i18n/messages/ko.json | 10 +- src/i18n/messages/mr.json | 10 +- src/i18n/messages/ms.json | 10 +- src/i18n/messages/nl.json | 10 +- src/i18n/messages/no.json | 10 +- src/i18n/messages/phi.json | 10 +- src/i18n/messages/pl.json | 10 +- src/i18n/messages/pt-BR.json | 10 +- src/i18n/messages/pt.json | 10 +- src/i18n/messages/ro.json | 10 +- src/i18n/messages/ru.json | 10 +- src/i18n/messages/sk.json | 10 +- src/i18n/messages/sv.json | 10 +- src/i18n/messages/sw.json | 10 +- src/i18n/messages/ta.json | 10 +- src/i18n/messages/te.json | 10 +- src/i18n/messages/th.json | 10 +- src/i18n/messages/tr.json | 10 +- src/i18n/messages/uk-UA.json | 10 +- src/i18n/messages/ur.json | 10 +- src/i18n/messages/vi.json | 10 +- src/i18n/messages/zh-CN.json | 10 +- src/i18n/messages/zh-TW.json | 10 +- src/shared/validation/settingsSchemas.ts | 21 ++ .../unit/headroom-url-settings-12306.test.ts | 153 ++++++++++++ 47 files changed, 789 insertions(+), 41 deletions(-) create mode 100644 changelog.d/features/12306-headroom-url.md create mode 100644 src/app/(dashboard)/dashboard/settings/components/HeadroomProxyCard.tsx create mode 100644 tests/unit/headroom-url-settings-12306.test.ts diff --git a/changelog.d/features/12306-headroom-url.md b/changelog.d/features/12306-headroom-url.md new file mode 100644 index 0000000000..7478e094f8 --- /dev/null +++ b/changelog.d/features/12306-headroom-url.md @@ -0,0 +1 @@ +- **feat(settings):** persist `headroomUrl` through Settings so status/start use the operator URL instead of only `HEADROOM_URL` ([#12306](https://github.com/diegosouzapw/OmniRoute/issues/12306)) diff --git a/src/app/(dashboard)/dashboard/settings/advanced/page.tsx b/src/app/(dashboard)/dashboard/settings/advanced/page.tsx index 5dfefd170b..af8eb23deb 100644 --- a/src/app/(dashboard)/dashboard/settings/advanced/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/advanced/page.tsx @@ -5,6 +5,7 @@ import LogToolSourcesCard from "../components/LogToolSourcesCard"; import PayloadRulesTab from "../components/PayloadRulesTab"; import RequestLimitsTab from "../components/RequestLimitsTab"; import CliproxyapiSettingsTab from "../components/CliproxyapiSettingsTab"; +import HeadroomProxyCard from "../components/HeadroomProxyCard"; export default function SettingsAdvancedPage() { return ( @@ -14,6 +15,7 @@ export default function SettingsAdvancedPage() { +
); } diff --git a/src/app/(dashboard)/dashboard/settings/components/HeadroomProxyCard.tsx b/src/app/(dashboard)/dashboard/settings/components/HeadroomProxyCard.tsx new file mode 100644 index 0000000000..de37387cec --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/HeadroomProxyCard.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card, Button, Input } from "@/shared/components"; +import { isHttpUrl } from "@/shared/validation/schemas/misc"; + +const HEADROOM_URL_MAX = 500; + +function isValidHeadroomUrl(value: string): boolean { + const trimmed = value.trim(); + if (trimmed === "") return true; + return trimmed.length <= HEADROOM_URL_MAX && isHttpUrl(trimmed); +} + +type SettingsErrorBody = { + error?: { + message?: string; + details?: { field?: string; message?: string }[]; + }; +}; + +function settingsErrorText(body: SettingsErrorBody, fallback: string): string { + const first = body.error?.details?.[0]; + if (first?.message) { + return first.field ? `${first.field}: ${first.message}` : first.message; + } + return body.error?.message || fallback; +} + +interface HeadroomStatus { + url?: string; + running?: boolean; + canStart?: boolean; + localUrl?: boolean; + installed?: boolean; +} + +export default function HeadroomProxyCard() { + const t = useTranslations("settings"); + const [url, setUrl] = useState(""); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const [acting, setActing] = useState(false); + const [status, setStatus] = useState(null); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + const saveAc = useRef(null); + const lifecycleAc = useRef(null); + + const refreshStatus = useCallback(async (signal?: AbortSignal) => { + const res = await fetch("/api/headroom/status", signal ? { signal } : undefined); + if (!res.ok) return; + const data = (await res.json()) as HeadroomStatus; + if (signal?.aborted) return; + setStatus(data); + }, []); + + useEffect(() => { + const ac = new AbortController(); + // Async continuation so every setState happens after an await + // (react-hooks/set-state-in-effect: no synchronous setState in effect bodies). + void (async () => { + try { + const r = await fetch("/api/settings", { signal: ac.signal }); + const data = (r.ok ? await r.json() : {}) as Record; + if (ac.signal.aborted) return; + if (typeof data.headroomUrl === "string") setUrl(data.headroomUrl); + } catch { + // ignore + } finally { + if (!ac.signal.aborted) setLoaded(true); + } + // Status is for start/stop buttons only. Do not copy status.url into the + // input -- that value is HEADROOM_URL fallback and would overwrite empty. + try { + await refreshStatus(ac.signal); + } catch { + // ignore + } + })(); + return () => { + ac.abort(); + saveAc.current?.abort(); + lifecycleAc.current?.abort(); + }; + }, [refreshStatus]); + + const save = useCallback(async () => { + if (!isValidHeadroomUrl(url)) { + setMsg({ ok: false, text: t("cliproxyapiInvalidUrl") }); + return; + } + saveAc.current?.abort(); + const ac = new AbortController(); + saveAc.current = ac; + const { signal } = ac; + setSaving(true); + setMsg(null); + const trimmed = url.trim(); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ headroomUrl: trimmed }), + signal, + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as SettingsErrorBody; + throw new Error(settingsErrorText(body, `HTTP ${res.status}`)); + } + if (signal.aborted) return; + setUrl(trimmed); + setMsg({ ok: true, text: t("settingSaved") }); + } catch (error) { + if (signal.aborted) return; + setMsg({ + ok: false, + text: error instanceof Error ? error.message : t("settingSaveFailed"), + }); + return; + } finally { + if (saveAc.current === ac) setSaving(false); + } + try { + await refreshStatus(signal); + } catch { + // PATCH already succeeded; status is best-effort. + } + }, [url, t, refreshStatus]); + + const postLifecycle = useCallback( + async (path: "/api/headroom/start" | "/api/headroom/stop") => { + lifecycleAc.current?.abort(); + const ac = new AbortController(); + lifecycleAc.current = ac; + const { signal } = ac; + setActing(true); + setMsg(null); + try { + const res = await fetch(path, { method: "POST", signal }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as SettingsErrorBody; + throw new Error(settingsErrorText(body, `HTTP ${res.status}`)); + } + try { + await refreshStatus(signal); + } catch { + // start/stop already succeeded; status is best-effort. + } + } catch (error) { + if (signal.aborted) return; + setMsg({ + ok: false, + text: error instanceof Error ? error.message : t("settingSaveFailed"), + }); + } finally { + if (lifecycleAc.current === ac) setActing(false); + } + }, + [refreshStatus, t] + ); + + if (!loaded) return null; + + const canStart = status?.canStart === true; + const running = status?.running === true; + const busy = saving || acting; + + return ( + +
+
+ compress +
+
+

{t("headroomProxyTitle")}

+

{t("headroomProxyDesc")}

+
+
+ + {msg && ( +
+ + {msg.ok ? "check_circle" : "error"} + + {msg.text} +
+ )} + +
+
+ + setUrl(e.target.value)} + placeholder="http://localhost:8787" + className="w-full" + disabled={busy} + /> +

{t("headroomProxyUrlHint")}

+
+
+ + + +
+ {status && !canStart && !status.localUrl && ( +

{t("headroomProxyExternalHint")}

+ )} +
+
+ ); +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index d33a7633bf..00fd85e21c 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "الصحة", "cliproxyapiPort": "منفذ", "qdrantHost": "مضيف", - "qdrantCollection": "مجموعة" + "qdrantCollection": "مجموعة", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "محرك آر تي كيه", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index a50b88762f..7d3eed8c7e 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Sağlamlıq", "cliproxyapiPort": "Port", "qdrantHost": "Ev sahibi", - "qdrantCollection": "Kolleksiya" + "qdrantCollection": "Kolleksiya", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 8086f96fc5..b83456a253 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Здраве", "cliproxyapiPort": "Порт", "qdrantHost": "Хост", - "qdrantCollection": "Колекция" + "qdrantCollection": "Колекция", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 507007dc7b..767b25ffd6 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "স্বাস্থ্য", "cliproxyapiPort": "পোর্ট", "qdrantHost": "হোস্ট", - "qdrantCollection": "সংগ্রহ" + "qdrantCollection": "সংগ্রহ", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index f68aa4b51d..d23ce1eb61 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Zdraví", "cliproxyapiPort": "Port", "qdrantHost": "Host", - "qdrantCollection": "Kolekce" + "qdrantCollection": "Kolekce", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index f326e8f313..5c26437fb6 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Sundhed", "cliproxyapiPort": "Port", "qdrantHost": "Vært", - "qdrantCollection": "Samling" + "qdrantCollection": "Samling", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 4b51536213..6ded55b40a 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Gesundheit", "cliproxyapiPort": "Port", "qdrantHost": "Host", - "qdrantCollection": "Sammlung" + "qdrantCollection": "Sammlung", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 9424b64ed7..dbfd697cd4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -7910,6 +7910,14 @@ "cliproxyapiFallback": "CLIProxyAPI Fallback", "cliproxyapiEnableFallback": "Enable CLIProxyAPI Fallback", "cliproxyapiUrl": "CLIProxyAPI URL", + "headroomProxyTitle": "Headroom proxy", + "headroomProxyDesc": "URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "Headroom URL", + "headroomProxyUrlHint": "Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "Save", + "headroomProxyStart": "Start", + "headroomProxyStop": "Stop", + "headroomProxyExternalHint": "This URL is not loopback, so OmniRoute will not spawn the local CLI.", "cliproxyapiStatus": "CLIProxyAPI Status", "cliproxyapiNotDetected": "Not detected", "cliproxyapiImportAuthTitle": "Import accounts from CLIProxyAPI", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index b1fbcc4fe6..c9ca5f5f3e 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Salud", "cliproxyapiPort": "Puerto", "qdrantHost": "Anfitrión", - "qdrantCollection": "Colección" + "qdrantCollection": "Colección", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index b57be44976..3186d8a26a 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "سلامت", "cliproxyapiPort": "پورت", "qdrantHost": "میزبان", - "qdrantCollection": "مجموعه" + "qdrantCollection": "مجموعه", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 0b220d98b7..3b40f11caf 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Terveys", "cliproxyapiPort": "Portti", "qdrantHost": "Isäntä", - "qdrantCollection": "Kokoelma" + "qdrantCollection": "Kokoelma", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 59cae55660..bee6010d70 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Santé", "cliproxyapiPort": "Port", "qdrantHost": "Hôte", - "qdrantCollection": "Collection" + "qdrantCollection": "Collection", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 152237a606..4becbd4de1 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "આરોગ્ય", "cliproxyapiPort": "પોર્ટ", "qdrantHost": "હોસ્ટ", - "qdrantCollection": "સંગ્રહ" + "qdrantCollection": "સંગ્રહ", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 7f45b461c0..ca44b00147 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "בריאות", "cliproxyapiPort": "פורט", "qdrantHost": "מארח", - "qdrantCollection": "אוסף" + "qdrantCollection": "אוסף", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 3867eeb43a..9730073a62 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "स्वास्थ्य", "cliproxyapiPort": "पोर्ट", "qdrantHost": "होस्ट", - "qdrantCollection": "संग्रह" + "qdrantCollection": "संग्रह", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 5334482837..d033965d4f 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Egészség", "cliproxyapiPort": "Port", "qdrantHost": "Gazda", - "qdrantCollection": "Gyűjtemény" + "qdrantCollection": "Gyűjtemény", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 545e9a5a41..12ce90c987 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Kesehatan", "cliproxyapiPort": "Port", "qdrantHost": "Host", - "qdrantCollection": "Koleksi" + "qdrantCollection": "Koleksi", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index dc01def708..e5a5761760 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Salute", "cliproxyapiPort": "Porta", "qdrantHost": "Host", - "qdrantCollection": "Collezione" + "qdrantCollection": "Collezione", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index ba8c064462..a4162c3b15 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "健康", "cliproxyapiPort": "ポート", "qdrantHost": "ホスト", - "qdrantCollection": "コレクション" + "qdrantCollection": "コレクション", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index ab4b4a67f7..64275df522 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "건강", "cliproxyapiPort": "포트", "qdrantHost": "호스트", - "qdrantCollection": "컬렉션" + "qdrantCollection": "컬렉션", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 53af82d57a..d340f63917 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "आरोग्य", "cliproxyapiPort": "पोर्ट", "qdrantHost": "होस्ट", - "qdrantCollection": "संग्रह" + "qdrantCollection": "संग्रह", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 05b504ffde..f6b839fd7e 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Kesihatan", "cliproxyapiPort": "Pelabuhan", "qdrantHost": "Hos", - "qdrantCollection": "Koleksi" + "qdrantCollection": "Koleksi", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index c50a7beebc..db912312d2 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Gezondheid", "cliproxyapiPort": "Haven", "qdrantHost": "Host", - "qdrantCollection": "Verzameling" + "qdrantCollection": "Verzameling", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 5f7a1c64b0..4394a1e27b 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Helse", "cliproxyapiPort": "Port", "qdrantHost": "Vert", - "qdrantCollection": "Samling" + "qdrantCollection": "Samling", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 3a3080eded..89183a593d 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Kalusugan", "cliproxyapiPort": "Port", "qdrantHost": "Host", - "qdrantCollection": "Koleksyon" + "qdrantCollection": "Koleksyon", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index bd07b8321e..8f5c91da84 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Zdrowie", "cliproxyapiPort": "Port", "qdrantHost": "Host", - "qdrantCollection": "Kolekcja" + "qdrantCollection": "Kolekcja", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "Silnik RTK", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 5199f22e2c..0199b1b463 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -8344,7 +8344,15 @@ "cliproxyapiHealth": "Saúde", "cliproxyapiPort": "Porta", "qdrantHost": "Host", - "qdrantCollection": "Coleção" + "qdrantCollection": "Coleção", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "Motor RTK", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 3533f6370a..6cd1a1aa6c 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Saúde", "cliproxyapiPort": "Porto", "qdrantHost": "Anfitrião", - "qdrantCollection": "Coleção" + "qdrantCollection": "Coleção", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "Motor RTK", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index afe466f408..4f445c4476 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Sănătate", "cliproxyapiPort": "Port", "qdrantHost": "Gazdă", - "qdrantCollection": "Colecție" + "qdrantCollection": "Colecție", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index dbd845f383..d740cc5faf 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Здоровье", "cliproxyapiPort": "Порт", "qdrantHost": "Хост", - "qdrantCollection": "Коллекция" + "qdrantCollection": "Коллекция", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 40f2003b77..5dcf60ac13 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Zdravie", "cliproxyapiPort": "Port", "qdrantHost": "Host", - "qdrantCollection": "Zbierka" + "qdrantCollection": "Zbierka", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 175c8e1f97..bc502df18d 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Hälsa", "cliproxyapiPort": "Port", "qdrantHost": "Värd", - "qdrantCollection": "Samling" + "qdrantCollection": "Samling", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index b8288d3863..7301fb6774 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Afya", "cliproxyapiPort": "Bandari", "qdrantHost": "Mwenyeji", - "qdrantCollection": "Mkusanyiko" + "qdrantCollection": "Mkusanyiko", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index e0614361fc..346590dc7f 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "ஆரோக்கியம்", "cliproxyapiPort": "போர்ட்", "qdrantHost": "விருந்தினர்", - "qdrantCollection": "கலெக்ஷன்" + "qdrantCollection": "கலெக்ஷன்", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 6dfa5290b7..5cbdf5c885 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "ఆరోగ్యం", "cliproxyapiPort": "పోర్ట్", "qdrantHost": "హోస్ట్", - "qdrantCollection": "సేకరణ" + "qdrantCollection": "సేకరణ", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 126df99e1b..68f90b88a6 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "สุขภาพ", "cliproxyapiPort": "พอร์ต", "qdrantHost": "โฮสต์", - "qdrantCollection": "การรวบรวม" + "qdrantCollection": "การรวบรวม", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 5ee309e90d..6ed5f68933 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Sağlık", "cliproxyapiPort": "Port", "qdrantHost": "Ana Bilgisayar", - "qdrantCollection": "Koleksiyon" + "qdrantCollection": "Koleksiyon", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index e1203b1d1b..5601b38668 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "Здоров'я", "cliproxyapiPort": "Порт", "qdrantHost": "Хост", - "qdrantCollection": "Колекція" + "qdrantCollection": "Колекція", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "Двигун RTK", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index fdc5391976..e22f742090 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "صحت", "cliproxyapiPort": "پورٹ", "qdrantHost": "میزبان", - "qdrantCollection": "اجتماع" + "qdrantCollection": "اجتماع", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 874cab83b8..d0a948d93b 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -8344,7 +8344,15 @@ "cliproxyapiHealth": "Sức Khỏe", "cliproxyapiPort": "Cổng", "qdrantHost": "Máy chủ", - "qdrantCollection": "Bộ Sưu Tập" + "qdrantCollection": "Bộ Sưu Tập", + "headroomProxyTitle": "Proxy Headroom", + "headroomProxyDesc": "URL của proxy tiết kiệm token Headroom (tùy chọn). Để trống thì dùng HEADROOM_URL hoặc http://localhost:8787.", + "headroomProxyUrl": "URL Headroom", + "headroomProxyUrlHint": "URL loopback có thể khởi chạy từ trang này. URL bên ngoài chỉ được kiểm tra.", + "headroomProxySave": "Lưu", + "headroomProxyStart": "Bắt đầu", + "headroomProxyStop": "Dừng", + "headroomProxyExternalHint": "URL này không phải loopback, nên OmniRoute sẽ không khởi chạy CLI cục bộ." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 4306b98ac2..8501b6d0b4 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "健康", "cliproxyapiPort": "端口", "qdrantHost": "主机", - "qdrantCollection": "集合" + "qdrantCollection": "集合", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "命令输出过滤引擎", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 1a4db6102b..66689e9057 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -8340,7 +8340,15 @@ "cliproxyapiHealth": "健康", "cliproxyapiPort": "埠", "qdrantHost": "主機", - "qdrantCollection": "集合" + "qdrantCollection": "集合", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK 引擎", diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 55f1c49645..a07651d587 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -23,6 +23,7 @@ import { SPAWN_CAPABLE_PREFIXES, SPAWN_CAPABLE_PATTERN_ANCESTORS, } from "@/shared/constants/spawnCapablePrefixes"; +import { isHttpUrl } from "@/shared/validation/schemas/misc"; const signatureCacheModeValues = ["enabled", "bypass", "bypass-strict"] as const; @@ -493,6 +494,26 @@ export const updateSettingsSchema = z.object({ // CLIProxyAPI connection settings cliproxyapi_fallback_enabled: z.boolean().optional(), cliproxyapi_url: z.string().url().max(500).optional(), + // #12306: external Headroom proxy URL. Empty = fall back to HEADROOM_URL / localhost:8787. + // Status/start already read this key; without the schema field PATCH strips it. + // Trim first so a padded URL matches the client (isValidHeadroomUrl trims) + // and whitespace-only becomes the empty fallback, not "Invalid URL". + // z.string().url() also accepts javascript:/data:/file:. probeProxyRunning + // interpolates this into fetch(`${url}/health`), so restrict to http(s). + headroomUrl: z + .string() + .trim() + .pipe( + z.union([ + z.literal(""), + z + .string() + .url() + .max(500) + .refine((value) => isHttpUrl(value), "must be an http(s) URL"), + ]) + ) + .optional(), cliproxyapi_fallback_codes: z.string().max(200).optional(), // #7645: dedicated CLIProxyAPI credential. CLIProxyAPI requires its own // separately-configured `api-keys:` credential and rejects any other token diff --git a/tests/unit/headroom-url-settings-12306.test.ts b/tests/unit/headroom-url-settings-12306.test.ts new file mode 100644 index 0000000000..e9c89c8638 --- /dev/null +++ b/tests/unit/headroom-url-settings-12306.test.ts @@ -0,0 +1,153 @@ +/** + * #12306: settings.headroomUrl must survive PATCH /api/settings. + * + * Status/start already READ settings.headroomUrl. Without the schema + * field Zod strips the key and the write path is a no-op. + */ +import { test, 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-12306-headroom-")); +const originalDataDir = process.env.DATA_DIR; +process.env.DATA_DIR = testDataDir; + +const { updateSettingsSchema } = await import("../../src/shared/validation/settingsSchemas.ts"); +const coreDb = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); + +after(() => { + coreDb.resetDbInstance(); + if (fs.existsSync(testDataDir)) { + fs.rmSync(testDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; +}); + +test("updateSettingsSchema keeps a valid headroomUrl", () => { + const parsed = updateSettingsSchema.parse({ + headroomUrl: "http://127.0.0.1:8787", + }); + assert.equal(parsed.headroomUrl, "http://127.0.0.1:8787"); +}); + +test("updateSettingsSchema accepts an empty headroomUrl to fall back to HEADROOM_URL", () => { + const parsed = updateSettingsSchema.parse({ headroomUrl: "" }); + assert.equal(parsed.headroomUrl, ""); +}); + +test("updateSettingsSchema trims a padded headroomUrl before validating", () => { + const parsed = updateSettingsSchema.parse({ + headroomUrl: " http://headroom.internal:9090 ", + }); + assert.equal(parsed.headroomUrl, "http://headroom.internal:9090"); +}); + +test("updateSettingsSchema treats whitespace-only headroomUrl as empty", () => { + const parsed = updateSettingsSchema.parse({ headroomUrl: " " }); + assert.equal(parsed.headroomUrl, ""); +}); + +test("updateSettingsSchema rejects a non-URL headroomUrl", () => { + const result = updateSettingsSchema.safeParse({ headroomUrl: "not-a-url" }); + assert.equal(result.success, false); +}); + +test("updateSettingsSchema rejects non-http(s) headroomUrl schemes", () => { + for (const url of [ + "javascript:alert(1)", + "ftp://x", + "data:text/html,x", + "file:///etc/passwd", + "http://", + "http://[", + "http:", + ]) { + const result = updateSettingsSchema.safeParse({ headroomUrl: url }); + assert.equal(result.success, false, url); + } +}); + +test("updateSettingsSchema rejects a headroomUrl over 500 chars", () => { + const result = updateSettingsSchema.safeParse({ + headroomUrl: `http://example.com/${"x".repeat(500)}`, + }); + assert.equal(result.success, false); +}); + +test("updateSettings round-trips a validated headroomUrl", async () => { + await coreDb.ensureDbInitialized(); + const parsed = updateSettingsSchema.parse({ + headroomUrl: "http://headroom.internal:9090", + }); + await settingsDb.updateSettings(parsed); + const stored = await settingsDb.getSettings(); + assert.equal(stored.headroomUrl, "http://headroom.internal:9090"); +}); + +test("updateSettings round-trips an empty headroomUrl without dropping the key", async () => { + await coreDb.ensureDbInitialized(); + await settingsDb.updateSettings( + updateSettingsSchema.parse({ headroomUrl: "http://headroom.internal:9090" }) + ); + await settingsDb.updateSettings(updateSettingsSchema.parse({ headroomUrl: "" })); + const stored = await settingsDb.getSettings(); + assert.equal(stored.headroomUrl, ""); +}); + +test("advanced settings page mounts the Headroom proxy card", async () => { + const src = fs.readFileSync( + path.join(import.meta.dirname, "../../src/app/(dashboard)/dashboard/settings/advanced/page.tsx"), + "utf8" + ); + assert.match(src, /HeadroomProxyCard/); +}); + +test("after() restores DATA_DIR so later files in the same process keep their own dir", () => { + const src = fs.readFileSync(new URL(import.meta.url), "utf8"); + assert.match(src, /const originalDataDir = process\.env\.DATA_DIR/); + assert.match(src, /if \(originalDataDir === undefined\) delete process\.env\.DATA_DIR/); +}); + +test("save reads PATCH validation details instead of a generic HTTP status", () => { + const src = fs.readFileSync( + path.join( + import.meta.dirname, + "../../src/app/(dashboard)/dashboard/settings/components/HeadroomProxyCard.tsx" + ), + "utf8" + ); + assert.match(src, /throw new Error\(settingsErrorText\(body, `HTTP \$\{res\.status\}`\)\)/); + assert.equal( + (src.match(/throw new Error\(settingsErrorText\(body, `HTTP \$\{res\.status\}`\)\)/g) || []).length, + 2 + ); + assert.match(src, /body\.error\?\.details/); + assert.match(src, /isHttpUrl/); + assert.match(src, /const HEADROOM_URL_MAX = 500/); + assert.match(src, /setUrl\(trimmed\)/); + assert.match(src, /const saveAc = useRef\(null\)/); + assert.match(src, /const lifecycleAc = useRef\(null\)/); + assert.match(src, /saveAc\.current = ac/); + assert.match(src, /lifecycleAc\.current = ac/); + assert.match(src, /await fetch\(path, \{ method: "POST", signal \}\)/); + assert.match(src, /body: JSON.stringify\(\{ headroomUrl: trimmed \}\),\s*signal,/s); + assert.match(src, /\/\/ start\/stop already succeeded; status is best-effort\./); + // Busy flags: clear only if this invocation still owns the controller. + // A second click replaces the ref; the first finally must not unlock. + assert.match(src, /if \(saveAc\.current === ac\) setSaving\(false\)/); + assert.match(src, /if \(lifecycleAc\.current === ac\) setActing\(false\)/); + assert.doesNotMatch(src, /if \(!signal\.aborted\) setSaving\(false\)/); + assert.doesNotMatch(src, /if \(!signal\.aborted\) setActing\(false\)/); + assert.match( + src, + /return \(\) => \{\s*ac\.abort\(\);\s*saveAc\.current\?\.abort\(\);\s*lifecycleAc\.current\?\.abort\(\);/s + ); + assert.match(src, /const busy = saving \|\| acting;/); + assert.match(src, /disabled=\{busy\}/); + assert.match(src, /disabled=\{busy \|\| !canStart\}/); + assert.match(src, /disabled=\{busy \|\| !running\}/); +}); From f81ce2a23b10495d931fa17ca5f02320bda0a554 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Thu, 3 Sep 2026 11:40:02 -0400 Subject: [PATCH 30/47] feat(dashboard): adaptive context-budget dial on compression panel (#12488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca. O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. --- CHANGELOG.md | 1 + docs/compression/COMPRESSION_GUIDE.md | 2 +- .../context/settings/CompressionPanel.tsx | 176 ++++- src/i18n/messages/ar.json | 8 + src/i18n/messages/az.json | 8 + src/i18n/messages/bg.json | 8 + src/i18n/messages/bn.json | 8 + src/i18n/messages/cs.json | 8 + src/i18n/messages/da.json | 8 + src/i18n/messages/de.json | 8 + src/i18n/messages/en.json | 8 + src/i18n/messages/es.json | 8 + src/i18n/messages/fa.json | 8 + src/i18n/messages/fi.json | 8 + src/i18n/messages/fr.json | 8 + src/i18n/messages/gu.json | 8 + src/i18n/messages/he.json | 8 + src/i18n/messages/hi.json | 8 + src/i18n/messages/hu.json | 8 + src/i18n/messages/id.json | 8 + src/i18n/messages/it.json | 8 + src/i18n/messages/ja.json | 8 + src/i18n/messages/ko.json | 8 + src/i18n/messages/mr.json | 8 + src/i18n/messages/ms.json | 8 + src/i18n/messages/nl.json | 8 + src/i18n/messages/no.json | 8 + src/i18n/messages/phi.json | 8 + src/i18n/messages/pl.json | 8 + src/i18n/messages/pt-BR.json | 8 + src/i18n/messages/pt.json | 8 + src/i18n/messages/ro.json | 8 + src/i18n/messages/ru.json | 8 + src/i18n/messages/sk.json | 8 + src/i18n/messages/sv.json | 8 + src/i18n/messages/sw.json | 8 + src/i18n/messages/ta.json | 8 + src/i18n/messages/te.json | 8 + src/i18n/messages/th.json | 8 + src/i18n/messages/tr.json | 8 + src/i18n/messages/uk-UA.json | 8 + src/i18n/messages/ur.json | 8 + src/i18n/messages/vi.json | 8 + src/i18n/messages/zh-CN.json | 8 + src/i18n/messages/zh-TW.json | 8 + .../ui/compressionAdaptiveBudgetDial.test.tsx | 660 ++++++++++++++++++ 46 files changed, 1142 insertions(+), 33 deletions(-) create mode 100644 tests/unit/ui/compressionAdaptiveBudgetDial.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d6fd097613..bfcad514f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### ✨ New Features +- **feat(dashboard):** adaptive context-budget dial on the compression settings panel — mode (`off` / `floor` / `replace-autotrigger`) and policy (`reserve-output` / `percentage` / `absolute`) persist via `PUT /api/settings/compression` `contextBudget`. Completes the dashboard half of #7005 (API + DB already shipped in #7183). - **feat(sse): STRICT_ZERO_COST** — opt-in, off-by-default `freeAccessPolicy: "strict"` setting that hard-verifies every auto-combo candidate against live quota state and per-connection economic safety before it can be dispatched, going beyond `hidePaidModels`'s static catalog diff --git a/docs/compression/COMPRESSION_GUIDE.md b/docs/compression/COMPRESSION_GUIDE.md index 8f51281fd6..bd2a9e665a 100644 --- a/docs/compression/COMPRESSION_GUIDE.md +++ b/docs/compression/COMPRESSION_GUIDE.md @@ -309,7 +309,7 @@ Every compressed request includes stats in the server logs: | Phase 2 | Standard, Aggressive, Ultra | ✅ Shipped | | Phase 3 | RTK, Stacked, Compression Combos | ✅ Shipped | | Phase 4 | Output Styles, SLM-tier Ultra, eval harness | ✅ Shipped | -| Phase 4C | Adaptive context-budget ("dial") — compute engine + API (`contextBudget` on `PUT /api/settings/compression`) | ✅ Shipped (API-configurable; dashboard controls not yet built, #7005) | +| Phase 4C | Adaptive context-budget ("dial") — compute engine + API (`contextBudget` on `PUT /api/settings/compression`) + dashboard mode/policy controls | ✅ Shipped | --- diff --git a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx index 4d7b506171..31bf43fff6 100644 --- a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx +++ b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx @@ -3,12 +3,13 @@ // CompressionPanel — the single-source engine-grid UI for compression. // // Renders the master on/off switch, one row per catalog engine (on/off + level + -// link to its detail page), the cavemanOutput intensity row, the mcpAccessibility -// toggle (its own endpoint / separate store), a read-only derived-pipeline preview, -// and the general settings (auto-trigger tokens + preserve-system-prompt). +// link to its detail page), the adaptive context-budget dial, the cavemanOutput +// intensity row, the mcpAccessibility toggle (its own endpoint / separate store), +// a derived-pipeline preview, and the general settings (auto-trigger tokens + +// preserve-system-prompt). // import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslations, useLocale } from "next-intl"; // Import Card/Toggle from their direct module paths rather than the @/shared/components // barrel: the barrel transitively pulls a heavy/Node-only module that hangs the @@ -60,12 +61,20 @@ interface CompressionConfig { // Best-effort pre-warm of the SLM model on enable / cold restart. Default false. ultraSlmPrewarm?: boolean; // Phase 4 (C): adaptive context-budget. Absent / mode:"off" = legacy auto-trigger. - // The panel currently surfaces the computed target read-only; mode/policy editors are a - // follow-up (the load/save path does not yet populate this field). contextBudget?: ContextBudgetConfig; liveZone?: { enabled: boolean }; } +const CONTEXT_BUDGET_MODES = new Set([ + "off", + "floor", + "replace-autotrigger", +]); +const CONTEXT_BUDGET_POLICIES = new Set([ + "reserve-output", + "percentage", + "absolute", +]); const CAVEMAN_OUTPUT_LEVELS: CavemanIntensity[] = ["lite", "full", "ultra"]; const DEFAULT_CONFIG: CompressionConfig = { @@ -78,6 +87,7 @@ const DEFAULT_CONFIG: CompressionConfig = { outputStyles: [], ultraEngine: "heuristic", ultraSlmPrewarm: false, + contextBudget: { ...DEFAULT_CONTEXT_BUDGET }, liveZone: { enabled: false }, }; @@ -120,22 +130,73 @@ function LiveZoneToggle({ ); } -function AdaptiveTargetPreview({ contextBudget }: { contextBudget?: ContextBudgetConfig }) { +function AdaptiveContextBudgetDial({ + contextBudget, + saving, + onChange, +}: { + contextBudget: ContextBudgetConfig; + saving: boolean; + onChange: (patch: Partial) => void; +}) { const t = useTranslations("settings"); - const target = getAdaptiveTargetSummary(contextBudget ?? DEFAULT_CONTEXT_BUDGET, 200000); + // Representative window for the preview label (D-C1). Not the live model limit — + // the panel has no selected-model context here; 200k is Claude-class default. + const target = getAdaptiveTargetSummary(contextBudget, 200000); return ( -
- {target.enabled - ? t("compressionAdaptiveTarget", { - mode: target.mode, - policy: target.policy, - target: target.target, - contextLimit: target.contextLimit, - }) - : t("compressionAdaptiveOff")} +
+ + {(contextBudget.mode ?? "off") !== "off" && ( + + )} +
+ {target.enabled + ? t("compressionAdaptiveTarget", { + mode: target.mode, + policy: target.policy, + target: target.target, + contextLimit: target.contextLimit, + }) + : t("compressionAdaptiveOff")} +
); } @@ -153,19 +214,29 @@ export default function CompressionPanel() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [status, setStatus] = useState<"" | "saved" | "error">(""); + const configRef = useRef(config); + useEffect(() => { + configRef.current = config; + }, [config]); + const saveGenRef = useRef(0); + const lastConfirmedRef = useRef(config); + const lastAckedGenRef = useRef(0); useEffect(() => { fetch("/api/settings/compression") .then((r) => (r.ok ? r.json() : null)) .then((data: Partial | null) => { if (data) { - setConfig({ + const hydrated: CompressionConfig = { ...DEFAULT_CONFIG, ...data, engines: normalizeEngines(data.engines), cavemanOutputMode: data.cavemanOutputMode ?? DEFAULT_CONFIG.cavemanOutputMode, outputStyles: data.outputStyles ?? DEFAULT_CONFIG.outputStyles, - }); + contextBudget: { ...DEFAULT_CONTEXT_BUDGET, ...(data.contextBudget ?? {}) }, + }; + lastConfirmedRef.current = hydrated; + setConfig(hydrated); } }) .catch(() => {}) @@ -181,8 +252,24 @@ export default function CompressionPanel() { // Persist a merge-patch. The DB persists `engines` as one whole row, so callers that // touch an engine pass the full engines map to avoid dropping the other engines. + // Generation + configRef: a later in-flight save must not let an older failure + // roll back a newer optimistic (or already-acked) state. const save = async (updates: Partial) => { - const next = { ...config, ...updates }; + const gen = ++saveGenRef.current; + const previous = configRef.current; + const next: CompressionConfig = { + ...previous, + ...updates, + ...(updates.contextBudget + ? { + contextBudget: { + ...(previous.contextBudget ?? DEFAULT_CONTEXT_BUDGET), + ...updates.contextBudget, + }, + } + : {}), + }; + configRef.current = next; setConfig(next); setSaving(true); setStatus(""); @@ -192,16 +279,34 @@ export default function CompressionPanel() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(updates), }); - if (res.ok) { - setStatus("saved"); - setTimeout(() => setStatus(""), 2000); - } else { - setStatus("error"); + // Acked server state is recorded even when this gen is stale, so a + // later failure rolls back to the newest acked PUT, not the GET. + // lastAckedGenRef stops an older ack from overwriting a newer one. + if (res.ok && gen >= lastAckedGenRef.current) { + lastConfirmedRef.current = next; + lastAckedGenRef.current = gen; + } + if (gen === saveGenRef.current) { + if (res.ok) { + setStatus("saved"); + const savedGen = gen; + setTimeout(() => { + if (savedGen === saveGenRef.current) setStatus(""); + }, 2000); + } else { + configRef.current = lastConfirmedRef.current; + setConfig(lastConfirmedRef.current); + setStatus("error"); + } } } catch { - setStatus("error"); + if (gen === saveGenRef.current) { + configRef.current = lastConfirmedRef.current; + setConfig(lastConfirmedRef.current); + setStatus("error"); + } } finally { - setSaving(false); + if (gen === saveGenRef.current) setSaving(false); } }; @@ -326,8 +431,15 @@ export default function CompressionPanel() { {derivedText}
- {/* Adaptive context-budget — read-only computed target (Phase 4C, D-C1 transparency) */} - + {/* Adaptive context-budget dial — mode/policy persist via PUT contextBudget */} + { + const current = configRef.current.contextBudget ?? DEFAULT_CONTEXT_BUDGET; + save({ contextBudget: { ...current, ...patch } }); + }} + /> {/* Engine grid */}
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 00fd85e21c..b987620b46 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "الوضع: {mode}", "compressionAdaptiveOff": "ميزانية السياق التكيفية: معطلة (المشغل التلقائي القديم)", "compressionAdaptiveTarget": "تكيفي ({mode}، السياسة: {policy}) — الهدف ≈ {target, number} رمز (لنافذة من {contextLimit, number} رمز)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "حقن تعليمات تشكيل الاستجابة دون إعادة كتابة مخرجات المزود. ادمج بحرية.", "mcpAccessibilityDescription": "يحدد نطاق مخرجات أداة MCP (مخزن منفصل).", "compressionStylesTileSummary": "{tokens, number} رمز تم توفيره · {runs, plural, one {# تشغيل تم تنسيقه} other {# تشغيلات تم تنسيقها}}", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 7d3eed8c7e..33217b0d2b 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "rejim: {mode}", "compressionAdaptiveOff": "Adaptiv kontekst büdcəsi: qapalı (köhnə avtomatik tətikləyici)", "compressionAdaptiveTarget": "Adaptiv ({mode}, siyasət: {policy}) — hədəf ≈ {target, number} token ({contextLimit, number}-tokenlik pəncərə üçün)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Provayder çıxışını yenidən yazmadan cavab-formalaşdıran təlimatları daxil edin. Sərbəst şəkildə birləşdirin.", "mcpAccessibilityDescription": "MCP alət çıxışlarını əhatə edir (ayrıca depo).", "compressionStylesTileSummary": "{tokens, number} tokenə qənaət edilib · {runs, plural, one {# işəsalma üslublaşdırılıb} other {# işəsalma üslublaşdırılıb}}", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index b83456a253..3c3c942e6d 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "режим: {mode}", "compressionAdaptiveOff": "Адаптивен бюджет за контекст: изключен (наследено автоматично задействане)", "compressionAdaptiveTarget": "Адаптивен ({mode}, политика: {policy}) — цел ≈ {target, number} токена (за прозорец от {contextLimit, number} токена)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Вмъкване на инструкции за оформяне на отговора без пренаписване на изхода от доставчика. Комбинирайте свободно.", "mcpAccessibilityDescription": "Ограничава обхвата на изходите от MCP инструменти (отделно хранилище).", "compressionStylesTileSummary": "{tokens, number} спестени токена · {runs, plural, one {# стилизирано изпълнение} other {# стилизирани изпълнения}}", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 767b25ffd6..36cee7c2d9 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "মোড: {mode}", "compressionAdaptiveOff": "অ্যাডাপ্টিভ কনটেক্সট বাজেট: বন্ধ (লেগাসি অটো-ট্রিগার)", "compressionAdaptiveTarget": "অ্যাডাপ্টিভ ({mode}, পলিসি: {policy}) — টার্গেট ≈ {target, number} টোকেন ({contextLimit, number}-টোকেন উইন্ডোর জন্য)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "প্রোভাইডার আউটপুট রিরাইট না করেই রেসপন্স-শেপিং নির্দেশাবলী ইনজেক্ট করুন। অবাধে একত্রিত করুন।", "mcpAccessibilityDescription": "MCP টুল আউটপুট স্কোপ করে (আলাদা স্টোর)।", "compressionStylesTileSummary": "{tokens, number} টোকেন সাশ্রয় হয়েছে · {runs, plural, one {#টি রান স্টাইল করা হয়েছে} other {#টি রান স্টাইল করা হয়েছে}}", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index d23ce1eb61..568a126332 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "režim: {mode}", "compressionAdaptiveOff": "Adaptivní rozpočet kontextu: vypnuto (starší automatické spouštění)", "compressionAdaptiveTarget": "Adaptivní ({mode}, zásada: {policy}) — cíl ≈ {target, number} tokenů (pro okno o velikosti {contextLimit, number} tokenů)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Vkládejte instrukce pro formování odpovědi bez přepisování výstupu poskytovatele. Libovolně kombinujte.", "mcpAccessibilityDescription": "Omezuje rozsah výstupů nástrojů MCP (samostatné úložiště).", "compressionStylesTileSummary": "{tokens, number} ušetřených tokenů · {runs, plural, one {# stylované spuštění} few {# stylovaná spuštění} other {# stylovaných spuštění}}", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 5c26437fb6..2f19b8d75a 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "tilstand: {mode}", "compressionAdaptiveOff": "Adaptivt kontekstbudget: fra (forældet auto-trigger)", "compressionAdaptiveTarget": "Adaptiv ({mode}, politik: {policy}) — mål ≈ {target, number} tokens (for et {contextLimit, number}-token vindue)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Indsæt instruktioner til formning af svar uden at omskrive udbyderens output. Kombiner frit.", "mcpAccessibilityDescription": "Afgrænser MCP-værktøjsoutput (separat lager).", "compressionStylesTileSummary": "{tokens, number} tokens sparet · {runs, plural, one {# kørsel stylet} other {# kørsler stylet}}", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 6ded55b40a..b6c9eccefc 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "Modus: {mode}", "compressionAdaptiveOff": "Adaptives Kontextbudget: aus (Legacy-Auto-Trigger)", "compressionAdaptiveTarget": "Adaptiv ({mode}, Richtlinie: {policy}) — Ziel ≈ {target, number} Tokens (für ein {contextLimit, number}-Token-Fenster)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Anweisungen zur Antwortgestaltung einfügen, ohne die Provider-Ausgabe umzuschreiben. Frei kombinierbar.", "mcpAccessibilityDescription": "Schränkt MCP-Tool-Ausgaben ein (separater Speicher).", "compressionStylesTileSummary": "{tokens, number} Tokens eingespart · {runs, plural, one {# Ausführung gestylt} other {# Ausführungen gestylt}}", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index dbfd697cd4..ff70e5c7bb 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -7815,6 +7815,14 @@ "compressionDerivedMode": "mode: {mode}", "compressionAdaptiveOff": "Adaptive context budget: off (legacy auto-trigger)", "compressionAdaptiveTarget": "Adaptive ({mode}, policy: {policy}) — target ≈ {target, number} tokens (for a {contextLimit, number}-token window)", + "compressionAdaptiveMode": "Adaptive context budget", + "compressionAdaptiveModeOff": "Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "Replace auto-trigger", + "compressionAdaptivePolicy": "Budget policy", + "compressionAdaptivePolicyReserve": "Reserve output", + "compressionAdaptivePolicyPercentage": "Percentage of window", + "compressionAdaptivePolicyAbsolute": "Absolute token budget", "compressionOutputStylesDescription": "Inject response-shaping instructions without rewriting provider output. Combine freely.", "mcpAccessibilityDescription": "Scopes MCP tool outputs (separate store).", "compressionStylesTileSummary": "{tokens, number} tokens saved · {runs, plural, one {# run styled} other {# runs styled}}", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index c9ca5f5f3e..89f8137d05 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "mode: {mode}", "compressionAdaptiveOff": "Adaptive context budget: off (legacy auto-trigger)", "compressionAdaptiveTarget": "Adaptive ({mode}, policy: {policy}) — target ≈ {target, number} tokens (for a {contextLimit, number}-token window)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Inject response-shaping instructions without rewriting provider output. Combine freely.", "mcpAccessibilityDescription": "Scopes MCP tool outputs (separate store).", "compressionStylesTileSummary": "{tokens, number} tokens saved · {runs, plural, one {# run styled} other {# runs styled}}", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 3186d8a26a..fb8fab5f8b 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "حالت: {mode}", "compressionAdaptiveOff": "بودجه محتوای تطبیقی: خاموش (محرک خودکار قدیمی)", "compressionAdaptiveTarget": "تطبیقی ({mode}، خط‌مشی: {policy}) — هدف ≈ {target, number} توکن (برای یک پنجره {contextLimit, number} توکنی)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "تزریق دستورالعمل‌های شکل‌دهی به پاسخ بدون بازنویسی خروجی ارائه‌دهنده. ترکیب آزادانه.", "mcpAccessibilityDescription": "محدوده خروجی‌های ابزار MCP (ذخیره‌ساز مجزا).", "compressionStylesTileSummary": "{tokens, number} توکن ذخیره شد · {runs, plural, one {# اجرا سبک‌دهی شد} other {# اجرا سبک‌دهی شدند}}", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 3b40f11caf..75173454c9 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "tila: {mode}", "compressionAdaptiveOff": "Mukautuva kontekstibudjetti: pois päältä (vanha automaattikäynnistys)", "compressionAdaptiveTarget": "Mukautuva ({mode}, käytäntö: {policy}) — tavoite ≈ {target, number} tokenia ({contextLimit, number} tokenin ikkunalle)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Lisää vastauksen muotoiluohjeita kirjoittamatta palveluntarjoajan tulostetta uudelleen. Yhdistele vapaasti.", "mcpAccessibilityDescription": "Rajaa MCP-työkalujen tulosteet (erillinen tallennustila).", "compressionStylesTileSummary": "{tokens, number} tokenia säästetty · {runs, plural, one {# ajo tyylitelty} other {# ajoa tyylitelty}}", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index bee6010d70..e3d9dc6bab 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "mode : {mode}", "compressionAdaptiveOff": "Budget de contexte adaptatif : désactivé (déclenchement automatique hérité)", "compressionAdaptiveTarget": "Adaptatif ({mode}, politique : {policy}) — cible ≈ {target, number} jetons (pour une fenêtre de {contextLimit, number} jetons)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Injecter des instructions de mise en forme des réponses sans réécrire la sortie du fournisseur. À combiner librement.", "mcpAccessibilityDescription": "Limite la portée des sorties d'outils MCP (magasin distinct).", "compressionStylesTileSummary": "{tokens, number} jetons économisés · {runs, plural, one {# exécution stylisée} other {# exécutions stylisées}}", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 4becbd4de1..21a08458e5 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "મોડ: {mode}", "compressionAdaptiveOff": "અડેપ્ટિવ કન્ટેક્સ્ટ બજેટ: બંધ (લેગસી ઑટો-ટ્રિગર)", "compressionAdaptiveTarget": "અડેપ્ટિવ ({mode}, પૉલિસી: {policy}) — લક્ષ્ય ≈ {target, number} ટોકન્સ ({contextLimit, number}-ટોકન વિન્ડો માટે)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "પ્રોવાઇડર આઉટપુટને ફરીથી લખ્યા વિના રિસ્પોન્સ-શેપિંગ સૂચનાઓ ઇન્જેક્ટ કરો. મુક્તપણે જોડો.", "mcpAccessibilityDescription": "MCP ટૂલ આઉટપુટ્સને સ્કોપ કરે છે (અલગ સ્ટોર).", "compressionStylesTileSummary": "{tokens, number} ટોકન્સ સાચવ્યા · {runs, plural, one {# રન સ્ટાઇલ કરેલ} other {# રન સ્ટાઇલ કરેલ}}", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index ca44b00147..5cbdf78924 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "מצב: {mode}", "compressionAdaptiveOff": "תקציב הקשר אדפטיבי: כבוי (טריגר אוטומטי מיושן)", "compressionAdaptiveTarget": "אדפטיבי ({mode}, מדיניות: {policy}) — יעד ≈ {target, number} טוקנים (עבור חלון של {contextLimit, number} טוקנים)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "הזרקת הנחיות לעיצוב תגובה מבלי לשכתב את פלט הספק. ניתן לשלב באופן חופשי.", "mcpAccessibilityDescription": "מגביל את הטווח של פלטי כלי MCP (אחסון נפרד).", "compressionStylesTileSummary": "{tokens, number} טוקנים נחסכו · {runs, plural, one {הרצה אחת עוצבה} other {# הרצות עוצבו}}", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 9730073a62..844ea16376 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "मोड: {mode}", "compressionAdaptiveOff": "अनुकूली संदर्भ बजट: बंद (लीगेसी ऑटो-ट्रिगर)", "compressionAdaptiveTarget": "अनुकूली ({mode}, नीति: {policy}) — लक्ष्य ≈ {target, number} टोकन ({contextLimit, number}-टोकन विंडो के लिए)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "प्रदाता आउटपुट को फिर से लिखे बिना प्रतिक्रिया-आकार देने वाले निर्देश इंजेक्ट करें। स्वतंत्र रूप से संयोजित करें।", "mcpAccessibilityDescription": "MCP टूल आउटपुट को स्कोप करता है (अलग स्टोर)।", "compressionStylesTileSummary": "{tokens, number} टोकन बचाए गए · {runs, plural, one {# रन स्टाइल किया गया} other {# रन स्टाइल किए गए}}", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index d033965d4f..d5741d4b62 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "mód: {mode}", "compressionAdaptiveOff": "Adaptív kontextuskeret: kikapcsolva (örökölt automatikus indítás)", "compressionAdaptiveTarget": "Adaptív ({mode}, szabályzat: {policy}) — cél ≈ {target, number} token ({contextLimit, number} tokenes ablakhoz)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Válaszformáló utasítások beillesztése a szolgáltató kimenetének átírása nélkül. Szabadon kombinálható.", "mcpAccessibilityDescription": "Hatókörbe foglalja az MCP-eszközök kimeneteit (külön tároló).", "compressionStylesTileSummary": "{tokens, number} token megtakarítva · {runs, plural, one {# stílusozott futtatás} other {# stílusozott futtatás}}", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 12ce90c987..95f4ca816a 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "mode: {mode}", "compressionAdaptiveOff": "Anggaran konteks adaptif: nonaktif (pemicu otomatis warisan)", "compressionAdaptiveTarget": "Adaptif ({mode}, kebijakan: {policy}) — target ≈ {target, number} token (untuk jendela {contextLimit, number}-token)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Suntikkan instruksi pembentukan respons tanpa menulis ulang output penyedia. Kombinasikan secara bebas.", "mcpAccessibilityDescription": "Membatasi cakupan output alat MCP (penyimpanan terpisah).", "compressionStylesTileSummary": "{tokens, number} token disimpan · {runs, plural, one {# eksekusi digayakan} other {# eksekusi digayakan}}", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index e5a5761760..4b77e9d30c 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "modalità: {mode}", "compressionAdaptiveOff": "Budget del contesto adattivo: disattivato (attivazione automatica legacy)", "compressionAdaptiveTarget": "Adattivo ({mode}, criterio: {policy}) — target ≈ {target, number} token (per una finestra di {contextLimit, number} token)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Inserisci istruzioni per modellare la risposta senza riscrivere l'output del provider. Combina liberamente.", "mcpAccessibilityDescription": "Limita l'ambito degli output degli strumenti MCP (archivio separato).", "compressionStylesTileSummary": "{tokens, number} token risparmiati · {runs, plural, one {# esecuzione stilizzata} other {# esecuzioni stilizzate}}", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index a4162c3b15..ce2d30773a 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "モード: {mode}", "compressionAdaptiveOff": "アダプティブコンテキストバジェット: オフ(レガシー自動トリガー)", "compressionAdaptiveTarget": "アダプティブ({mode}、ポリシー: {policy})— ターゲット ≈ {target, number} トークン({contextLimit, number} トークンウィンドウ用)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "プロバイダーの出力を書き換えることなく、レスポンス整形指示を挿入します。自由に組み合わせ可能です。", "mcpAccessibilityDescription": "MCPツールの出力をスコープします(別ストア)。", "compressionStylesTileSummary": "{tokens, number} トークン節約 · {runs, plural, one {# 回の実行にスタイル適用} other {# 回の実行にスタイル適用}}", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 64275df522..c6a703ccac 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "모드: {mode}", "compressionAdaptiveOff": "적응형 컨텍스트 예산: 꺼짐 (기존 자동 트리거)", "compressionAdaptiveTarget": "적응형 ({mode}, 정책: {policy}) — 대상 ≈ {target, number} 토큰 ({contextLimit, number} 토큰 창 기준)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "제공자 출력을 다시 작성하지 않고 응답 형성 지침을 주입합니다. 자유롭게 조합하세요.", "mcpAccessibilityDescription": "MCP 도구 출력의 범위를 제한합니다(별도 저장소).", "compressionStylesTileSummary": "{tokens, number} 토큰 절약됨 · {runs, plural, one {#개 실행 스타일 지정됨} other {#개 실행 스타일 지정됨}}", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index d340f63917..aeeb20b219 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "मोड: {mode}", "compressionAdaptiveOff": "अ‍ॅडॉप्टिव्ह संदर्भ बजेट: बंद (लेगसी ऑटो-ट्रिगर)", "compressionAdaptiveTarget": "अ‍ॅडॉप्टिव्ह ({mode}, पॉलिसी: {policy}) — लक्ष्य ≈ {target, number} टोकन्स ({contextLimit, number}-टोकन विंडोसाठी)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "प्रोव्हाइडर आउटपुट पुन्हा न लिहिता रिस्पॉन्स-शेपिंग सूचना इंजेक्ट करा. मुक्तपणे एकत्र करा.", "mcpAccessibilityDescription": "MCP टूल आउटपुटची व्याप्ती ठरवते (स्वतंत्र स्टोअर).", "compressionStylesTileSummary": "{tokens, number} टोकन्स वाचवले · {runs, plural, one {# रन स्टाईल केला} other {# रन्स स्टाईल केले}}", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index f6b839fd7e..b506128049 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "mod: {mode}", "compressionAdaptiveOff": "Belanjawan konteks adaptif: mati (pencetus automatik legasi)", "compressionAdaptiveTarget": "Adaptif ({mode}, dasar: {policy}) — sasaran ≈ {target, number} token (untuk tetingkap {contextLimit, number}-token)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Suntik arahan pembentukan respons tanpa menulis semula output penyedia. Gabungkan secara bebas.", "mcpAccessibilityDescription": "Menskupkan output alat MCP (storan berasingan).", "compressionStylesTileSummary": "{tokens, number} token dijimatkan · {runs, plural, one {# larian digayakan} other {# larian digayakan}}", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index db912312d2..d92b373142 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "modus: {mode}", "compressionAdaptiveOff": "Adaptief contextbudget: uit (legacy auto-trigger)", "compressionAdaptiveTarget": "Adaptief ({mode}, beleid: {policy}) — doel ≈ {target, number} tokens (voor een venster van {contextLimit, number} tokens)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Injecteer instructies voor responsvormgeving zonder de uitvoer van de provider te herschrijven. Vrij te combineren.", "mcpAccessibilityDescription": "Beperkt de scope van MCP-tooluitvoer (afzonderlijke opslag).", "compressionStylesTileSummary": "{tokens, number} tokens bespaard · {runs, plural, one {# run gestyled} other {# runs gestyled}}", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 4394a1e27b..1e415be8aa 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "modus: {mode}", "compressionAdaptiveOff": "Adaptivt kontekstbudsjett: av (foreldet automatisk utløser)", "compressionAdaptiveTarget": "Adaptiv ({mode}, policy: {policy}) — mål ≈ {target, number} tokener (for et vindu på {contextLimit, number} tokener)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Sett inn instruksjoner for responsforming uten å omskrive leverandørutdata. Kombiner fritt.", "mcpAccessibilityDescription": "Avgrenser MCP-verktøyutdata (eget lager).", "compressionStylesTileSummary": "{tokens, number} tokener spart · {runs, plural, one {# kjøring stiltilpasset} other {# kjøringer stiltilpasset}}", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 89183a593d..da06b21c62 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "mode: {mode}", "compressionAdaptiveOff": "Adaptive context budget: naka-off (legacy na auto-trigger)", "compressionAdaptiveTarget": "Adaptive ({mode}, patakaran: {policy}) — target ≈ {target, number} na token (para sa isang {contextLimit, number}-token na window)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Mag-inject ng mga tagubilin sa paghubog ng tugon nang hindi muling isinusulat ang output ng provider. Malayang pagsamahin.", "mcpAccessibilityDescription": "Nililimitahan ang mga output ng MCP tool (hiwalay na store).", "compressionStylesTileSummary": "{tokens, number} na token ang na-save · {runs, plural, one {# run ang na-style} other {# na run ang na-style}}", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 8f5c91da84..ee567fe371 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "tryb: {mode}", "compressionAdaptiveOff": "Adaptacyjny budżet kontekstu: wył. (starszy automatyczny wyzwalacz)", "compressionAdaptiveTarget": "Adaptacyjny ({mode}, polityka: {policy}) — cel ≈ {target, number} tokenów (dla okna o rozmiarze {contextLimit, number} tokenów)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Wstrzykuj instrukcje kształtujące odpowiedź bez przepisywania danych wyjściowych dostawcy. Łącz dowolnie.", "mcpAccessibilityDescription": "Ogranicza zakres danych wyjściowych narzędzi MCP (osobny magazyn).", "compressionStylesTileSummary": "{tokens, number} tokenów zaoszczędzonych · {runs, plural, one {# przebieg ostylowany} few {# przebiegi ostylowane} many {# przebiegów ostylowanych} other {# przebiegów ostylowanych}}", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 0199b1b463..3fbe27e908 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -7816,6 +7816,14 @@ "compressionDerivedMode": "modo: {mode}", "compressionAdaptiveOff": "Orçamento de contexto adaptativo: desligado (auto-gatilho legado)", "compressionAdaptiveTarget": "Adaptativo ({mode}, política: {policy}) — alvo ≈ {target, number} tokens (para uma janela de {contextLimit, number} tokens)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Injeta instruções de modelagem de resposta sem reescrever a saída do provedor. Combine livremente.", "mcpAccessibilityDescription": "Delimita o escopo das saídas de ferramentas MCP (armazenamento separado).", "compressionStylesTileSummary": "{tokens, number} tokens economizados · {runs, plural, one {# execução estilizada} other {# execuções estilizadas}}", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 6cd1a1aa6c..4919461ca0 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "modo: {mode}", "compressionAdaptiveOff": "Orçamento de contexto adaptativo: desativado (acionamento automático legado)", "compressionAdaptiveTarget": "Adaptativo ({mode}, política: {policy}) — alvo ≈ {target, number} tokens (para uma janela de {contextLimit, number} tokens)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Injete instruções de modelação de resposta sem reescrever a saída do fornecedor. Combine livremente.", "mcpAccessibilityDescription": "Delimita as saídas das ferramentas MCP (armazenamento separado).", "compressionStylesTileSummary": "{tokens, number} tokens poupados · {runs, plural, one {# execução estilizada} other {# execuções estilizadas}}", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 4f445c4476..2d012143f9 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "mod: {mode}", "compressionAdaptiveOff": "Buget de context adaptiv: dezactivat (declanșare automată moștenită)", "compressionAdaptiveTarget": "Adaptiv ({mode}, politică: {policy}) — țintă ≈ {target, number} tokenuri (pentru o fereastră de {contextLimit, number} tokenuri)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Injectează instrucțiuni de modelare a răspunsului fără a rescrie ieșirea furnizorului. Combină liber.", "mcpAccessibilityDescription": "Limitează domeniul de aplicare al ieșirilor instrumentelor MCP (stocare separată).", "compressionStylesTileSummary": "{tokens, number} tokenuri salvate · {runs, plural, one {# rulare stilizată} few {# rulări stilizate} other {# de rulări stilizate}}", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index d740cc5faf..9f6bd4762e 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "режим: {mode}", "compressionAdaptiveOff": "Адаптивный бюджет контекста: выкл. (устаревший автотриггер)", "compressionAdaptiveTarget": "Адаптивный ({mode}, политика: {policy}) — цель ≈ {target, number} токенов (для окна в {contextLimit, number} токенов)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Внедряйте инструкции по формированию ответов без перезаписи вывода провайдера. Комбинируйте свободно.", "mcpAccessibilityDescription": "Ограничивает область вывода инструментов MCP (отдельное хранилище).", "compressionStylesTileSummary": "{tokens, number} токенов сэкономлено · {runs, plural, one {# запуск стилизован} few {# запуска стилизовано} many {# запусков стилизовано} other {# запуска стилизовано}}", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 5dcf60ac13..b5fc50f625 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "režim: {mode}", "compressionAdaptiveOff": "Adaptívny rozpočet kontextu: vypnuté (starší automatický spúšťač)", "compressionAdaptiveTarget": "Adaptívny ({mode}, politika: {policy}) — cieľ ≈ {target, number} tokenov (pre okno s {contextLimit, number} tokenmi)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Vložiť inštrukcie na formovanie odpovede bez prepisovania výstupu poskytovateľa. Voľne kombinujte.", "mcpAccessibilityDescription": "Obmedzuje rozsah výstupov nástrojov MCP (samostatné úložisko).", "compressionStylesTileSummary": "{tokens, number} tokenov ušetrených · {runs, plural, one {# štylizované spustenie} few {# štylizované spustenia} other {# štylizovaných spustení}}", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index bc502df18d..eb8a2bd8f3 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "läge: {mode}", "compressionAdaptiveOff": "Adaptiv kontextbudget: av (äldre auto-trigger)", "compressionAdaptiveTarget": "Adaptiv ({mode}, policy: {policy}) — mål ≈ {target, number} tokens (för ett fönster på {contextLimit, number} tokens)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Infoga instruktioner för svarsformning utan att skriva om leverantörens utdata. Kombinera fritt.", "mcpAccessibilityDescription": "Avgränsar MCP-verktygsutdata (separat lagring).", "compressionStylesTileSummary": "{tokens, number} tokens sparade · {runs, plural, one {# körning stylad} other {# körningar stylade}}", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 7301fb6774..31c55e5173 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "hali: {mode}", "compressionAdaptiveOff": "Bajeti ya muktadha inayobadilika: imezimwa (kichochezi cha zamani cha kiotomatiki)", "compressionAdaptiveTarget": "Inayobadilika ({mode}, sera: {policy}) — lengo ≈ tokeni {target, number} (kwa dirisha la tokeni {contextLimit, number})", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Ingiza maagizo ya kuunda majibu bila kuandika upya matokeo ya mtoa huduma. Changanya kwa uhuru.", "mcpAccessibilityDescription": "Inaweka mipaka ya matokeo ya zana ya MCP (hifadhi tofauti).", "compressionStylesTileSummary": "{tokens, number} tokeni zimehifadhiwa · {runs, plural, one {# umewekewa mtindo} other {# imewekewa mtindo}}", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 346590dc7f..49679193d9 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "முறை: {mode}", "compressionAdaptiveOff": "தகவமைப்பு சூழல் பட்ஜெட்: ஆஃப் (பழைய தானியங்கு-தூண்டுதல்)", "compressionAdaptiveTarget": "தகவமைப்பு ({mode}, கொள்கை: {policy}) — இலக்கு ≈ {target, number} டோக்கன்கள் ({contextLimit, number}-டோக்கன் சாளரத்திற்கு)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "வழங்குநரின் வெளியீட்டை மீண்டும் எழுதாமல் பதில்-வடிவமைப்பு வழிமுறைகளை உட்செலுத்தவும். தாராளமாக இணைக்கவும்.", "mcpAccessibilityDescription": "MCP கருவி வெளியீடுகளை வரம்பிற்குள் வைக்கிறது (தனிச் சேமிப்பகம்).", "compressionStylesTileSummary": "{tokens, number} டோக்கன்கள் சேமிக்கப்பட்டன · {runs, plural, one {# இயக்கம் வடிவமைக்கப்பட்டது} other {# இயக்கங்கள் வடிவமைக்கப்பட்டன}}", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 5cbdf5c885..1a5223b1c5 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "మోడ్: {mode}", "compressionAdaptiveOff": "అడాప్టివ్ కాంటెక్స్ట్ బడ్జెట్: ఆఫ్ (లెగసీ ఆటో-ట్రిగ్గర్)", "compressionAdaptiveTarget": "అడాప్టివ్ ({mode}, పాలసీ: {policy}) — టార్గెట్ ≈ {target, number} టోకెన్‌లు ({contextLimit, number}-టోకెన్ విండో కోసం)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "ప్రొవైడర్ అవుట్‌పుట్‌ను తిరిగి రాయకుండా రెస్పాన్స్-షేపింగ్ సూచనలను ఇంజెక్ట్ చేయండి. స్వేచ్ఛగా కలపండి.", "mcpAccessibilityDescription": "MCP టూల్ అవుట్‌పుట్‌లను స్కోప్ చేస్తుంది (ప్రత్యేక స్టోర్).", "compressionStylesTileSummary": "{tokens, number} టోకెన్‌లు ఆదా చేయబడ్డాయి · {runs, plural, one {# రన్ స్టైల్ చేయబడింది} other {# రన్‌లు స్టైల్ చేయబడ్డాయి}}", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 68f90b88a6..a7093e6758 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "โหมด: {mode}", "compressionAdaptiveOff": "งบประมาณบริบทแบบปรับตัว: ปิด (การทริกเกอร์อัตโนมัติแบบเก่า)", "compressionAdaptiveTarget": "แบบปรับตัว ({mode}, นโยบาย: {policy}) — เป้าหมาย ≈ {target, number} โทเค็น (สำหรับหน้าต่าง {contextLimit, number} โทเค็น)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "แทรกคำสั่งกำหนดรูปแบบการตอบกลับโดยไม่ต้องเขียนผลลัพธ์ของผู้ให้บริการใหม่ สามารถผสมผสานได้อย่างอิสระ", "mcpAccessibilityDescription": "กำหนดขอบเขตผลลัพธ์เครื่องมือ MCP (แยกพื้นที่จัดเก็บ)", "compressionStylesTileSummary": "{tokens, number} โทเค็นที่ประหยัดได้ · {runs, plural, one {จัดรูปแบบแล้ว # ครั้ง} other {จัดรูปแบบแล้ว # ครั้ง}}", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 6ed5f68933..c7ea532a2f 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "mod: {mode}", "compressionAdaptiveOff": "Uyarlanabilir bağlam bütçesi: kapalı (eski otomatik tetikleyici)", "compressionAdaptiveTarget": "Uyarlanabilir ({mode}, politika: {policy}) — hedef ≈ {target, number} token ({contextLimit, number} tokenlık bir pencere için)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Sağlayıcı çıktısını yeniden yazmadan yanıt şekillendirme talimatları ekleyin. Serbestçe birleştirin.", "mcpAccessibilityDescription": "MCP araç çıktılarını kapsama alır (ayrı depo).", "compressionStylesTileSummary": "{tokens, number} token tasarruf edildi · {runs, plural, one {# çalıştırma stillendirildi} other {# çalıştırma stillendirildi}}", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 5601b38668..f6a1e7f0b1 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "режим: {mode}", "compressionAdaptiveOff": "Адаптивний бюджет контексту: вимкнено (застарілий автотригер)", "compressionAdaptiveTarget": "Адаптивний ({mode}, політика: {policy}) — ціль ≈ {target, number} токенів (для вікна в {contextLimit, number} токенів)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Впроваджуйте інструкції формування відповіді без перезапису виводу провайдера. Вільно комбінуйте.", "mcpAccessibilityDescription": "Обмежує область виводу інструментів MCP (окреме сховище).", "compressionStylesTileSummary": "{tokens, number} токенів збережено · {runs, plural, one {# стилізований запуск} few {# стилізовані запуски} many {# стилізованих запусків} other {# стилізованих запусків}}", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index e22f742090..bf80a21de8 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "موڈ: {mode}", "compressionAdaptiveOff": "اڈیپٹیو کانٹیکسٹ بجٹ: بند (لیگیسی آٹو ٹریگر)", "compressionAdaptiveTarget": "اڈیپٹیو ({mode}, پالیسی: {policy}) — ہدف ≈ {target, number} ٹوکنز ({contextLimit, number}-ٹوکن ونڈو کے لیے)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "پرووائیڈر آؤٹ پٹ کو دوبارہ لکھے بغیر رسپانس شیپنگ ہدایات شامل کریں۔ آزادانہ طور پر یکجا کریں۔", "mcpAccessibilityDescription": "MCP ٹول آؤٹ پٹس کو اسکوپ کرتا ہے (علیحدہ اسٹور)۔", "compressionStylesTileSummary": "{tokens, number} ٹوکنز محفوظ کیے گئے · {runs, plural, one {# رن اسٹائل کیا گیا} other {# رنز اسٹائل کیے گئے}}", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index d0a948d93b..f2e4a120bc 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -7816,6 +7816,14 @@ "compressionDerivedMode": "chế độ: {mode}", "compressionAdaptiveOff": "Ngân sách ngữ cảnh thích ứng: đã tắt (dùng ngưỡng tự động kích hoạt cũ)", "compressionAdaptiveTarget": "Thích ứng ({mode}, chính sách: {policy}) — mục tiêu ≈ {target, number} token (với cửa sổ {contextLimit, number} token)", + "compressionAdaptiveMode": "Ngân sách ngữ cảnh thích ứng", + "compressionAdaptiveModeOff": "Tắt (ngưỡng tự động kích hoạt cũ)", + "compressionAdaptiveModeFloor": "Sàn (luôn đảm bảo vừa cửa sổ)", + "compressionAdaptiveModeReplace": "Thay thế tự động kích hoạt", + "compressionAdaptivePolicy": "Chính sách ngân sách", + "compressionAdaptivePolicyReserve": "Dành chỗ cho đầu ra", + "compressionAdaptivePolicyPercentage": "Phần trăm cửa sổ", + "compressionAdaptivePolicyAbsolute": "Ngân sách token tuyệt đối", "compressionOutputStylesDescription": "Chèn hướng dẫn định hình phản hồi mà không viết lại đầu ra của nhà cung cấp. Có thể kết hợp tự do.", "mcpAccessibilityDescription": "Giới hạn phạm vi đầu ra của công cụ MCP (được lưu riêng).", "compressionStylesTileSummary": "Đã tiết kiệm {tokens, number} token · {runs, number} lượt áp dụng kiểu", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 8501b6d0b4..2301729df6 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "模式:{mode}", "compressionAdaptiveOff": "自适应上下文预算:关闭(旧版自动触发)", "compressionAdaptiveTarget": "自适应 ({mode}, 策略: {policy}) — 目标 ≈ {target, number} 个 token (针对 {contextLimit, number} 个 token 的窗口)", + "compressionAdaptiveMode": "自适应上下文预算", + "compressionAdaptiveModeOff": "关闭(旧版自动触发)", + "compressionAdaptiveModeFloor": "下限(始终保证适配)", + "compressionAdaptiveModeReplace": "替换自动触发", + "compressionAdaptivePolicy": "预算策略", + "compressionAdaptivePolicyReserve": "预留输出", + "compressionAdaptivePolicyPercentage": "窗口百分比", + "compressionAdaptivePolicyAbsolute": "绝对 token 预算", "compressionOutputStylesDescription": "注入响应塑造指令,而无需重写服务商输出。自由组合。", "mcpAccessibilityDescription": "限制 MCP 工具输出的范围 (独立存储)。", "compressionStylesTileSummary": "{tokens, number} 个 token 已节省 · {runs, plural, one {# 次运行已应用样式} other {# 次运行已应用样式}}", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 66689e9057..f0dea4fe5c 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -7812,6 +7812,14 @@ "compressionDerivedMode": "模式:{mode}", "compressionAdaptiveOff": "自適應上下文預算:關閉(傳統自動觸發)", "compressionAdaptiveTarget": "自適應({mode},策略:{policy})— 目標約 {target, number} tokens(針對 {contextLimit, number} token 的視窗)", + "compressionAdaptiveMode": "自適應上下文預算", + "compressionAdaptiveModeOff": "關閉(傳統自動觸發)", + "compressionAdaptiveModeFloor": "下限(始終保證適配)", + "compressionAdaptiveModeReplace": "替換自動觸發", + "compressionAdaptivePolicy": "預算策略", + "compressionAdaptivePolicyReserve": "預留輸出", + "compressionAdaptivePolicyPercentage": "視窗百分比", + "compressionAdaptivePolicyAbsolute": "絕對 token 預算", "compressionOutputStylesDescription": "注入回應塑形指令,無需改寫提供者輸出。可自由組合。", "mcpAccessibilityDescription": "限定 MCP 工具輸出範圍(獨立儲存區)。", "compressionStylesTileSummary": "{tokens, number} tokens saved · {runs, plural, one {# run styled} other {# runs styled}}", diff --git a/tests/unit/ui/compressionAdaptiveBudgetDial.test.tsx b/tests/unit/ui/compressionAdaptiveBudgetDial.test.tsx new file mode 100644 index 0000000000..5d4cd36afb --- /dev/null +++ b/tests/unit/ui/compressionAdaptiveBudgetDial.test.tsx @@ -0,0 +1,660 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { DEFAULT_CONTEXT_BUDGET } from "../../../open-sse/services/compression/adaptiveCompression/types.ts"; + +// i18n does not resolve to a real locale in vitest/jsdom, so mock next-intl to echo +// the key. This test asserts ONLY on i18n-independent hooks (data-testid + values) +// and the captured PUT body. +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})); + +const containers: HTMLElement[] = []; +const roots: Array<{ unmount: () => void }> = []; + +function mount(ui: React.ReactElement): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + roots.push(root); + act(() => root.render(ui)); + return container; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await act(async () => { + while (roots.length > 0) roots.pop()?.unmount(); + }); + for (let i = 0; i < 10; i++) await Promise.resolve(); + while (containers.length > 0) containers.pop()?.remove(); + document.body.innerHTML = ""; +}); + +async function flush() { + await act(async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }); +} + +interface CapturedPut { + url: string; + body: Record; +} + +function setupFetchMock( + overrides?: Record, + opts?: { putStatus?: number; putStatusFn?: (n: number) => number } +): { puts: CapturedPut[] } { + const puts: CapturedPut[] = []; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + const initial = { + enabled: true, + autoTriggerTokens: 0, + preserveSystemPrompt: true, + engines: {}, + activeComboId: null, + outputStyles: [], + cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true }, + ultraEngine: "heuristic", + ultraSlmPrewarm: false, + liveZone: { enabled: false }, + contextBudget: { ...DEFAULT_CONTEXT_BUDGET }, + ...overrides, + }; + vi.spyOn(globalThis, "fetch").mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/api/settings/compression/mcp-accessibility")) + return json({ enabled: true }); + if (url.includes("/api/settings/compression")) { + if (method === "PUT") { + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + puts.push({ url, body }); + const n = puts.length; + const status = opts?.putStatusFn ? opts.putStatusFn(n) : (opts?.putStatus ?? 200); + const merged = + body.contextBudget && typeof body.contextBudget === "object" + ? { + ...initial, + ...body, + contextBudget: { + ...(initial.contextBudget as Record), + ...(body.contextBudget as Record), + }, + } + : { ...initial, ...body }; + return json(merged, status); + } + return json(initial); + } + return json({}, 404); + } + ); + return { puts }; +} + +describe("CompressionPanel adaptive context-budget dial", () => { + it("renders the mode select defaulting to off (legacy auto-trigger)", async () => { + setupFetchMock(); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement | null; + expect(select, "mode select must render").toBeTruthy(); + expect(select?.value).toBe("off"); + expect(container.querySelector(`[data-testid="context-budget-policy-select"]`)).toBeFalsy(); + expect( + container.querySelector(`[data-testid="adaptive-target-preview"]`), + "preview label stays inside the dial" + ).toBeTruthy(); + }); + + it("hydrates mode off when GET omits contextBudget", async () => { + setupFetchMock({ contextBudget: undefined }); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement | null; + expect(select?.value).toBe("off"); + expect(container.querySelector(`[data-testid="context-budget-policy-select"]`)).toBeFalsy(); + }); + + it("hides the policy select when GET hydrates mode as null (same as off)", async () => { + setupFetchMock({ + contextBudget: { ...DEFAULT_CONTEXT_BUDGET, mode: null as unknown as "off" }, + }); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement | null; + expect(select?.value).toBe("off"); + expect(container.querySelector(`[data-testid="context-budget-policy-select"]`)).toBeFalsy(); + }); + + it("selecting floor PUTs the full contextBudget object with mode:'floor'", async () => { + const { puts } = setupFetchMock(); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + expect(select).toBeTruthy(); + await act(async () => { + select.value = "floor"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + + const put = puts.find((p) => "contextBudget" in p.body); + expect(put, "a PUT carrying contextBudget").toBeTruthy(); + const budget = put!.body.contextBudget as Record; + expect(budget.mode).toBe("floor"); + expect(budget.policy).toBe(DEFAULT_CONTEXT_BUDGET.policy); + expect(budget.outputReserve).toBe(DEFAULT_CONTEXT_BUDGET.outputReserve); + expect(budget.safetyMargin).toBe(DEFAULT_CONTEXT_BUDGET.safetyMargin); + expect(budget.pct).toBe(DEFAULT_CONTEXT_BUDGET.pct); + expect(budget.absoluteBudget).toBe(DEFAULT_CONTEXT_BUDGET.absoluteBudget); + expect(budget.ladderOverride).toBe(DEFAULT_CONTEXT_BUDGET.ladderOverride); + + expect( + container.querySelector(`[data-testid="context-budget-policy-select"]`), + "policy select appears once mode is not off" + ).toBeTruthy(); + }); + + it("selecting replace-autotrigger PUTs mode and reveals the policy select", async () => { + const { puts } = setupFetchMock(); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + await act(async () => { + select.value = "replace-autotrigger"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + + const put = puts.find((p) => "contextBudget" in p.body); + expect(put, "a PUT carrying contextBudget").toBeTruthy(); + const budget = put!.body.contextBudget as Record; + expect(budget.mode).toBe("replace-autotrigger"); + expect(container.querySelector(`[data-testid="context-budget-policy-select"]`)).toBeTruthy(); + }); + + it("rolls the mode select back to off when the PUT fails", async () => { + setupFetchMock(undefined, { putStatus: 500 }); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + await act(async () => { + select.value = "floor"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + + const after = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + expect(after.value).toBe("off"); + expect(container.querySelector(`[data-testid="context-budget-policy-select"]`)).toBeFalsy(); + }); + + it("rolls policy back to the hydrated value when the PUT fails", async () => { + const { puts } = setupFetchMock( + { + contextBudget: { + ...DEFAULT_CONTEXT_BUDGET, + mode: "floor", + policy: "reserve-output", + }, + }, + { putStatus: 500 } + ); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const policy = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + expect(policy.value).toBe("reserve-output"); + await act(async () => { + policy.value = "percentage"; + policy.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + + const after = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + expect(after.value).toBe("reserve-output"); + expect(puts).toHaveLength(1); + const budget = puts[0].body.contextBudget as Record; + expect(budget.mode).toBe("floor"); + expect(budget.policy).toBe("percentage"); + expect(budget.outputReserve).toBe(DEFAULT_CONTEXT_BUDGET.outputReserve); + expect(budget.absoluteBudget).toBe(DEFAULT_CONTEXT_BUDGET.absoluteBudget); + }); + + it("does not let an older failed PUT roll back a newer successful save", async () => { + const puts: CapturedPut[] = []; + const pending: Array<{ + resolve: (r: Response) => void; + body: Record; + }> = []; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + const initial = { + enabled: true, + autoTriggerTokens: 0, + preserveSystemPrompt: true, + engines: {}, + activeComboId: null, + outputStyles: [], + cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true }, + ultraEngine: "heuristic", + ultraSlmPrewarm: false, + liveZone: { enabled: false }, + contextBudget: { ...DEFAULT_CONTEXT_BUDGET }, + }; + vi.spyOn(globalThis, "fetch").mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/api/settings/compression/mcp-accessibility")) + return json({ enabled: true }); + if (url.includes("/api/settings/compression")) { + if (method === "PUT") { + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + puts.push({ url, body }); + return new Promise((resolve) => { + pending.push({ resolve, body }); + }); + } + return json(initial); + } + return json({}, 404); + } + ); + + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const mode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + const policy = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement | null; + expect(policy).toBeFalsy(); + + // Two PUTs from the same render: older one will 500 after the newer one 200s. + await act(async () => { + mode.value = "floor"; + mode.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(pending).toHaveLength(1); + + const policyAfter = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + expect(policyAfter, "policy select after optimistic floor").toBeTruthy(); + await act(async () => { + policyAfter.value = "percentage"; + policyAfter.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(pending).toHaveLength(2); + + await act(async () => { + pending[1].resolve(json({ ...initial, ...pending[1].body }, 200)); + }); + await flush(); + await act(async () => { + pending[0].resolve(json({ ...initial, ...pending[0].body }, 500)); + }); + await flush(); + + const afterMode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + const afterPolicy = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + expect(afterMode.value).toBe("floor"); + expect(afterPolicy.value).toBe("percentage"); + }); + + it("keeps an older successful PUT as lastConfirmed when a newer overlapping PUT fails", async () => { + const puts: CapturedPut[] = []; + const pending: Array<{ + resolve: (r: Response) => void; + body: Record; + }> = []; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + const initial = { + enabled: true, + autoTriggerTokens: 0, + preserveSystemPrompt: true, + engines: {}, + activeComboId: null, + outputStyles: [], + cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true }, + ultraEngine: "heuristic", + ultraSlmPrewarm: false, + liveZone: { enabled: false }, + contextBudget: { ...DEFAULT_CONTEXT_BUDGET }, + }; + vi.spyOn(globalThis, "fetch").mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/api/settings/compression/mcp-accessibility")) + return json({ enabled: true }); + if (url.includes("/api/settings/compression")) { + if (method === "PUT") { + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + puts.push({ url, body }); + return new Promise((resolve) => { + pending.push({ resolve, body }); + }); + } + return json(initial); + } + return json({}, 404); + } + ); + + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const mode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + await act(async () => { + mode.value = "floor"; + mode.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(pending).toHaveLength(1); + + const policyAfter = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + await act(async () => { + policyAfter.value = "percentage"; + policyAfter.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(pending).toHaveLength(2); + + // Older save A acks first (stale gen). Newer save B then 500s. + await act(async () => { + pending[0].resolve(json({ ...initial, ...pending[0].body }, 200)); + }); + await flush(); + await act(async () => { + pending[1].resolve(json({ ...initial, ...pending[1].body }, 500)); + }); + await flush(); + + const afterMode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + const afterPolicy = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + expect(afterMode.value).toBe("floor"); + expect( + afterPolicy, + "policy select stays — lastConfirmed is A's floor, not GET off" + ).toBeTruthy(); + expect(afterPolicy.value).toBe("reserve-output"); + }); + + it("rolls both overlapping failed PUTs back to the last GET snapshot, not the first optimistic state", async () => { + const puts: CapturedPut[] = []; + const pending: Array<{ + resolve: (r: Response) => void; + body: Record; + }> = []; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + const initial = { + enabled: true, + autoTriggerTokens: 0, + preserveSystemPrompt: true, + engines: {}, + activeComboId: null, + outputStyles: [], + cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true }, + ultraEngine: "heuristic", + ultraSlmPrewarm: false, + liveZone: { enabled: false }, + contextBudget: { ...DEFAULT_CONTEXT_BUDGET }, + }; + vi.spyOn(globalThis, "fetch").mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/api/settings/compression/mcp-accessibility")) + return json({ enabled: true }); + if (url.includes("/api/settings/compression")) { + if (method === "PUT") { + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + puts.push({ url, body }); + return new Promise((resolve) => { + pending.push({ resolve, body }); + }); + } + return json(initial); + } + return json({}, 404); + } + ); + + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const mode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + await act(async () => { + mode.value = "floor"; + mode.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(pending).toHaveLength(1); + + const policyAfter = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + await act(async () => { + policyAfter.value = "percentage"; + policyAfter.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(pending).toHaveLength(2); + + await act(async () => { + pending[1].resolve(json({ ...initial, ...pending[1].body }, 500)); + }); + await flush(); + await act(async () => { + pending[0].resolve(json({ ...initial, ...pending[0].body }, 500)); + }); + await flush(); + + const afterMode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + expect(afterMode.value).toBe("off"); + expect(container.querySelector(`[data-testid="context-budget-policy-select"]`)).toBeFalsy(); + }); + + it("does not let a stale saved-timeout clear a newer error status", async () => { + vi.useFakeTimers(); + try { + const { puts } = setupFetchMock(undefined, { + putStatusFn: (n) => (n === 1 ? 200 : 500), + }); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const mode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + await act(async () => { + mode.value = "floor"; + mode.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + expect(puts).toHaveLength(1); + expect(container.textContent).toContain("saved"); + + const policy = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + await act(async () => { + policy.value = "percentage"; + policy.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + expect(puts).toHaveLength(2); + expect(container.textContent).toContain("saveFailed"); + + await act(async () => { + vi.advanceTimersByTime(2000); + }); + await flush(); + expect(container.textContent).toContain("saveFailed"); + } finally { + vi.useRealTimers(); + } + }); + + it("hydrates GET contextBudget and changing policy PUTs the merged object", async () => { + const { puts } = setupFetchMock({ + contextBudget: { + ...DEFAULT_CONTEXT_BUDGET, + mode: "floor", + policy: "reserve-output", + }, + }); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const mode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + const policy = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + expect(mode.value).toBe("floor"); + expect(policy, "policy select must hydrate when mode is floor").toBeTruthy(); + expect(policy.value).toBe("reserve-output"); + + await act(async () => { + policy.value = "percentage"; + policy.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + + const put = puts.find((p) => "contextBudget" in p.body); + expect(put, "a PUT carrying contextBudget").toBeTruthy(); + const budget = put!.body.contextBudget as Record; + expect(budget.mode).toBe("floor"); + expect(budget.policy).toBe("percentage"); + expect(budget.pct).toBe(DEFAULT_CONTEXT_BUDGET.pct); + expect(budget.ladderOverride).toBe(DEFAULT_CONTEXT_BUDGET.ladderOverride); + }); +}); From 40c80756e4ad4664e8c633432d23df12b3958d74 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Thu, 3 Sep 2026 11:40:21 -0400 Subject: [PATCH 31/47] fix(quota): keep Antigravity Gemini usable when Claude weekly is empty (#12566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca. O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. --- open-sse/executors/antigravity.ts | 19 +- open-sse/services/accountFallback.ts | 13 +- .../services/antigravityFamilyCooldown.ts | 158 +++++++++++ open-sse/services/antigravityQuotaFamily.ts | 74 +++++ open-sse/services/combo.ts | 12 +- open-sse/services/combo/comboPredicates.ts | 23 +- open-sse/services/combo/nativeCodexTurnPin.ts | 3 +- .../services/combo/quotaExhaustionCutoff.ts | 6 +- open-sse/services/quotaPreflight.ts | 124 +++++---- src/domain/quotaCache.ts | 34 +-- src/sse/services/auth.ts | 50 +--- src/sse/services/quotaPreflightUnavailable.ts | 61 +++++ stryker.conf.json | 1 + ...agy-family-not-connection-cooldown.test.ts | 258 ++++++++++++++++++ ...ard-session-lease-bypass-inventory.test.ts | 2 + 15 files changed, 676 insertions(+), 162 deletions(-) create mode 100644 open-sse/services/antigravityFamilyCooldown.ts create mode 100644 src/sse/services/quotaPreflightUnavailable.ts create mode 100644 tests/unit/agy-family-not-connection-cooldown.test.ts diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index c16d626812..9582af9fe6 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -26,6 +26,7 @@ import { } from "../services/antigravityCredits.ts"; import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/creditBalance"; import { setConnectionRateLimitUntil } from "@/lib/db/providers"; +import { markAntigravityModelQuotaExhausted } from "../services/antigravityFamilyCooldown.ts"; import { getMitmAlias } from "@/lib/db/models"; import { MAX_ANTIGRAVITY_OUTPUT_TOKENS, @@ -245,17 +246,15 @@ export function createCreditsExtractionTransform( ); } -/** - * Persist a quota-exhausted cooldown to the DB for `connectionId` so that - * cross-request and post-restart routing skips this connection until the - * cooldown expires. Exported for unit testing. @internal - */ -export function markConnectionQuotaExhausted(connectionId: string, retryAfterMs: number): void { +export function markConnectionQuotaExhausted( + connectionId: string, + retryAfterMs: number, + model?: string | null +): void { try { + if (markAntigravityModelQuotaExhausted(connectionId, retryAfterMs, model)) return; setConnectionRateLimitUntil(connectionId, Date.now() + retryAfterMs); - } catch { - // DB write failure must never crash the request path - } + } catch {} } /** @@ -1620,7 +1619,7 @@ export class AntigravityExecutor extends BaseExecutor { updateAntigravityRemainingCredits ); if (creditsResult) return { kind: "return", result: creditsResult }; - if (retryMs) markConnectionQuotaExhausted(accountId, retryMs); + if (retryMs) markConnectionQuotaExhausted(accountId, retryMs, ctx.model); } return { diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index c1747cedc9..f5bd87c1ed 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -49,7 +49,8 @@ import { } from "../../src/shared/constants/providers"; import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints"; import { getCodexModelScope } from "../config/codexQuotaScopes.ts"; -import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts"; +import { getQuotaScopedModelForProvider, isAntigravityQuotaProvider } from "./antigravityQuotaFamily.ts"; +import { persistAntigravityFamilyCooldownIfQuota } from "./antigravityFamilyCooldown.ts"; import { classifyGeminiQuotaMetricFromText, isRpdExhausted, @@ -650,6 +651,9 @@ export async function recordCoreOwnedAntigravityQuotaState({ exactCooldownIsUpstreamReset: retryHintBypassesMaxCooldownMs(fallback.retryHintSource), } ); + if (lockout.cooldownMs > 0 && isProviderExhaustedReason(fallback)) { + persistAntigravityFamilyCooldownIfQuota({ provider, connectionId, model, cooldownMs: lockout.cooldownMs, reason: "quota_exhausted" }); + } return { cooldownMs: lockout.cooldownMs, failureCount: lockout.failureCount }; } @@ -2389,12 +2393,7 @@ export function applyErrorState( // (`markConnectionQuotaExhausted`) so a DB failure can never crash the // chat path. See issue #1 (per-account 429 cascade not persisting). const connId = (account as AccountState | null | undefined)?.id; - if ( - typeof connId === "string" && - connId.length > 0 && - effectiveCooldownMs > 0 && - nextState.rateLimitedUntil - ) { + if (typeof connId === "string" && connId.length > 0 && effectiveCooldownMs > 0 && nextState.rateLimitedUntil && !isAntigravityQuotaProvider(prov)) { try { const untilMs = cooldownUntilMs(nextState.rateLimitedUntil); if (Number.isFinite(untilMs) && untilMs > Date.now()) { diff --git a/open-sse/services/antigravityFamilyCooldown.ts b/open-sse/services/antigravityFamilyCooldown.ts new file mode 100644 index 0000000000..975822a88f --- /dev/null +++ b/open-sse/services/antigravityFamilyCooldown.ts @@ -0,0 +1,158 @@ +/** + * Persist Antigravity/agy quota cooldowns per model family (gemini vs claude) + * on the connection row, without cooling the whole account. + */ +import { lockModel } from "./accountFallback.ts"; +import { + getAntigravityQuotaFamily, + isAntigravityQuotaProvider, +} from "./antigravityQuotaFamily.ts"; + +type JsonRecord = Record; + +const FAMILY_PSD_KEY = "antigravityFamilyRateLimitedUntil"; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : {}; +} + +function parseUntilMs(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const ms = /^\d+(\.\d+)?$/.test(value.trim()) ? Number(value) : Date.parse(value); + return Number.isFinite(ms) ? ms : NaN; + } + return NaN; +} + +function dummyModelForFamily(family: "gemini" | "claude"): string { + return family === "gemini" ? "gemini-family-lock" : "claude-family-lock"; +} + +function lockAntigravityFamilyModel( + connectionId: string, + model: string, + reason: string, + cooldownMs: number +): void { + lockModel("agy", connectionId, model, reason, cooldownMs); + lockModel("antigravity", connectionId, model, reason, cooldownMs); +} + +export async function persistAntigravityFamilyCooldown(params: { + connectionId: string; + model: string; + rateLimitedUntil: string; +}): Promise { + if (!params.model.trim()) return null; + const family = getAntigravityQuotaFamily(params.model); + if (family === "other") return null; + + const { getProviderConnectionById, updateProviderConnection } = await import( + "@/lib/db/providers" + ); + const conn = (await getProviderConnectionById(params.connectionId)) as + | { provider?: string; providerSpecificData?: JsonRecord | null } + | null; + if (!conn || !isAntigravityQuotaProvider(conn.provider ?? null)) return null; + + const psd = asRecord(conn.providerSpecificData); + const untils = asRecord(psd[FAMILY_PSD_KEY]); + const existingMs = parseUntilMs(untils[family]); + const nextMs = parseUntilMs(params.rateLimitedUntil); + if (!Number.isFinite(nextMs)) return psd; + if (Number.isFinite(existingMs) && existingMs > Date.now() && existingMs >= nextMs) { + return psd; + } + + const nextPsd: JsonRecord = { + ...psd, + [FAMILY_PSD_KEY]: { ...untils, [family]: params.rateLimitedUntil }, + }; + await updateProviderConnection(params.connectionId, { providerSpecificData: nextPsd }); + return nextPsd; +} + +/** Fire-and-forget family PSD write. RPM/burst 429s must pass reason !== quota_exhausted. */ +export function persistAntigravityFamilyCooldownIfQuota(params: { + provider?: string | null; + connectionId: string; + model?: string | null; + cooldownMs: number; + reason?: string | null; +}): void { + if (!isAntigravityQuotaProvider(params.provider)) return; + if (!params.model?.trim() || params.cooldownMs <= 0) return; + if (params.reason != null && params.reason !== "quota_exhausted") return; + void persistAntigravityFamilyCooldown({ + connectionId: params.connectionId, + model: params.model, + rateLimitedUntil: new Date(Date.now() + params.cooldownMs).toISOString(), + }).catch(() => {}); +} + +export async function persistAntigravityPreflightFamilyLock(params: { + provider: string; + connectionId: string; + model: string; + unavailableUntil: string; +}): Promise { + const cooldownMs = Math.max(0, Date.parse(params.unavailableUntil) - Date.now()); + lockAntigravityFamilyModel(params.connectionId, params.model, "quota_exhausted", cooldownMs); + await persistAntigravityFamilyCooldown({ + connectionId: params.connectionId, + model: params.model, + rateLimitedUntil: params.unavailableUntil, + }); +} + +export function rehydrateAntigravityFamilyLocks( + provider: string, + connectionId: string, + providerSpecificData: JsonRecord | null | undefined +): void { + if (!isAntigravityQuotaProvider(provider)) return; + const untils = asRecord(asRecord(providerSpecificData)[FAMILY_PSD_KEY]); + const now = Date.now(); + for (const family of ["gemini", "claude"] as const) { + const untilMs = parseUntilMs(untils[family]); + if (!Number.isFinite(untilMs) || untilMs <= now) continue; + const model = dummyModelForFamily(family); + const remainingMs = untilMs - now; + lockAntigravityFamilyModel(connectionId, model, "quota_exhausted", remainingMs); + } +} + +export function rehydrateAntigravityFamilyLocksForConnections( + provider: string, + connections: Array<{ id: string; providerSpecificData?: unknown }> +): void { + if (!isAntigravityQuotaProvider(provider)) return; + for (const conn of connections) { + rehydrateAntigravityFamilyLocks( + provider, + conn.id, + conn.providerSpecificData as JsonRecord | null | undefined + ); + } +} + +/** Family lock for executor quota exhaustion. Returns false when model is absent. */ +export function markAntigravityModelQuotaExhausted( + connectionId: string, + retryAfterMs: number, + model?: string | null +): boolean { + if (!model) return false; + lockAntigravityFamilyModel(connectionId, model, "quota_exhausted", retryAfterMs); + persistAntigravityFamilyCooldownIfQuota({ + provider: "agy", + connectionId, + model, + cooldownMs: retryAfterMs, + reason: "quota_exhausted", + }); + return true; +} diff --git a/open-sse/services/antigravityQuotaFamily.ts b/open-sse/services/antigravityQuotaFamily.ts index e9ede18749..94016c1109 100644 --- a/open-sse/services/antigravityQuotaFamily.ts +++ b/open-sse/services/antigravityQuotaFamily.ts @@ -54,3 +54,77 @@ export function getQuotaScopeLabelForProvider( if (provider !== "antigravity" && provider !== "agy") return "model"; return getAntigravityQuotaFamily(model) === "other" ? "model" : "family"; } + +export function isAntigravityQuotaProvider(provider: string | null | undefined): boolean { + return provider === "antigravity" || provider === "agy"; +} + +export function quotaWindowNamesForScope( + names: string[], + scope?: { provider?: string | null; requestedModel?: string | null } +): string[] { + if (!scope?.requestedModel || !isAntigravityQuotaProvider(scope.provider)) return names; + const scoped = selectAntigravityQuotaWindowNames(names, scope.requestedModel); + return scoped.length > 0 ? scoped : names; +} + +/** Min remaining % across scoped windows, or 100 when an Antigravity family scope matched none. */ +export function remainingPercentFromQuotaWindows( + rawWindows: Record, + scope?: { provider?: string | null; requestedModel?: string | null } +): number | null { + const names = Object.keys(rawWindows); + const namesToScan = quotaWindowNamesForScope(names, scope); + let minRemaining: number | null = null; + for (const name of namesToScan) { + const windowInfo = rawWindows[name]; + if (!windowInfo || typeof windowInfo !== "object") continue; + const percentUsed = Number((windowInfo as Record).percentUsed); + if (!Number.isFinite(percentUsed)) continue; + const remaining = Math.max(0, Math.min(100, (1 - percentUsed) * 100)); + minRemaining = minRemaining === null ? remaining : Math.min(minRemaining, remaining); + } + if (minRemaining !== null) return minRemaining; + if (scope?.requestedModel && namesToScan !== names) return 100; + return null; +} + +/** + * Windows that belong to the requested Antigravity family. Claude weekly must + * not ride along on a Gemini request (and the reverse). + */ +export function selectAntigravityQuotaWindowNames( + quotaNames: string[], + requestedModel: string | null | undefined +): string[] { + if (!requestedModel) return quotaNames; + const requestedFamily = getAntigravityQuotaFamily(requestedModel); + const cleanRequestedModel = requestedModel.replace(/^(antigravity|agy)\//, ""); + const bareModel = cleanRequestedModel.includes("/") + ? cleanRequestedModel.slice(cleanRequestedModel.lastIndexOf("/") + 1) + : cleanRequestedModel; + + if (requestedFamily === "other") { + return quotaNames.filter((windowName) => { + const bare = windowName.replace(/^(antigravity|agy)\//, ""); + return bare === bareModel || bare === cleanRequestedModel; + }); + } + + const familyAggregates = + requestedFamily === "gemini" + ? ["gemini_weekly"] + : requestedFamily === "claude" + ? ["claude_gpt_weekly"] + : []; + + const exactWindows = quotaNames.filter((windowName) => { + const bare = windowName.replace(/^(antigravity|agy)\//, ""); + return bare === bareModel; + }); + const aggregateWindows = familyAggregates.filter((key) => quotaNames.includes(key)); + const scoped = [...exactWindows, ...aggregateWindows]; + if (scoped.length > 0) return scoped; + + return quotaNames.filter((windowName) => getAntigravityQuotaFamily(windowName) === requestedFamily); +} diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 9c316e52c7..c18cd37333 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -613,12 +613,13 @@ export async function buildAutoCandidates( const quota = await quotaPromises.get(quotaKey)!; resetWindowAffinity = calculateResetWindowAffinity(quota, resetWindowConfig); if (!quotaCutoffBlocked) { - quotaRemaining = quotaRemainingPercentFromQuota(quota); + quotaRemaining = quotaRemainingPercentFromQuota(quota, { provider, requestedModel: modelStr }); } if (!quotaCutoffBlocked && quotaCutoffEnabled) { const cutoffDecision = evaluateQuotaCutoff( quota as QuotaInfo | null, - buildAutoQuotaThresholds(provider, connection, resilienceSettings) + buildAutoQuotaThresholds(provider, connection, resilienceSettings), + { provider, requestedModel: modelStr } ); if (!cutoffDecision.proceed) { quotaCutoffBlocked = true; @@ -1379,7 +1380,7 @@ async function handleComboChatInner({ resilienceSettings, quotaCutoffResetWindowConfig, combo.name, - log + log, modelStr ); if (quotaCutoff.blocked) { log.info( @@ -4015,8 +4016,5 @@ async function handleRoundRobinCombo({ } log.warn("COMBO-RR", `All models failed | ${msg}`); - return new Response(JSON.stringify({ error: { message: msg } }), { - status, - headers: { "Content-Type": "application/json" }, - }); + return new Response(JSON.stringify({ error: { message: msg } }), { status, headers: { "Content-Type": "application/json" } }); } diff --git a/open-sse/services/combo/comboPredicates.ts b/open-sse/services/combo/comboPredicates.ts index 2c6acb099e..972abc5765 100644 --- a/open-sse/services/combo/comboPredicates.ts +++ b/open-sse/services/combo/comboPredicates.ts @@ -7,6 +7,7 @@ */ import { EXECUTOR_CONTRACT_VIOLATION_CODE } from "../../config/constants.ts"; +import { remainingPercentFromQuotaWindows } from "../antigravityQuotaFamily.ts"; import { errorResponse } from "../../utils/error.ts"; import { parseModel } from "../model.ts"; import { isSelfInflictedUpstreamTimeout } from "../../handlers/chatCore/cooldownClassification.ts"; @@ -431,24 +432,24 @@ export function clampPercent(value: number): number { return Math.max(0, Math.min(100, value)); } -export function quotaRemainingPercentFromQuota(quota: unknown): number { +export function quotaRemainingPercentFromQuota( + quota: unknown, + scope?: { provider?: string | null; requestedModel?: string | null } +): number { if (!quota || typeof quota !== "object") return 100; const record = quota as Record; - if (record.limitReached === true) return 0; const windows = record.windows; if (windows && typeof windows === "object" && !Array.isArray(windows)) { - let minRemaining: number | null = null; - for (const windowInfo of Object.values(windows as Record)) { - if (!windowInfo || typeof windowInfo !== "object") continue; - const percentUsed = Number((windowInfo as Record).percentUsed); - if (!Number.isFinite(percentUsed)) continue; - const remaining = clampPercent((1 - percentUsed) * 100); - minRemaining = minRemaining === null ? remaining : Math.min(minRemaining, remaining); - } - if (minRemaining !== null) return minRemaining; + const fromWindows = remainingPercentFromQuotaWindows( + windows as Record, + scope + ); + if (fromWindows !== null) return fromWindows; } + if (record.limitReached === true) return 0; + const percentUsed = Number(record.percentUsed); if (Number.isFinite(percentUsed)) return clampPercent((1 - percentUsed) * 100); return 100; diff --git a/open-sse/services/combo/nativeCodexTurnPin.ts b/open-sse/services/combo/nativeCodexTurnPin.ts index 9910d8d993..2b6d94945f 100644 --- a/open-sse/services/combo/nativeCodexTurnPin.ts +++ b/open-sse/services/combo/nativeCodexTurnPin.ts @@ -245,7 +245,8 @@ export async function isPinnedTargetModelScopedUnusable(args: { resilienceSettings, quotaCutoffResetWindowConfig, comboName, - log ?? { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } + log ?? { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + target.modelStr ); if (cutoff.blocked) return true; } diff --git a/open-sse/services/combo/quotaExhaustionCutoff.ts b/open-sse/services/combo/quotaExhaustionCutoff.ts index 2dc78dbe1b..a73d3628f4 100644 --- a/open-sse/services/combo/quotaExhaustionCutoff.ts +++ b/open-sse/services/combo/quotaExhaustionCutoff.ts @@ -97,7 +97,8 @@ export async function resolveQuotaExhaustionCutoffForTarget( resilienceSettings: ResilienceSettings | null | undefined, resetWindowConfig: ResetWindowConfig, comboName: string, - log: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void } + log: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void }, + requestedModel?: string | null ): Promise<{ blocked: boolean; reason?: string }> { const quotaCutoffEnabled = (resilienceSettings ?? resolveResilienceSettings(null))?.quotaPreflight?.enabled === true; @@ -126,7 +127,8 @@ export async function resolveQuotaExhaustionCutoffForTarget( }); const cutoffDecision = evaluateQuotaCutoff( quota as QuotaInfo | null, - buildAutoQuotaThresholds(provider, connection, resilienceSettings) + buildAutoQuotaThresholds(provider, connection, resilienceSettings), + { provider, requestedModel: requestedModel ?? null } ); if (!cutoffDecision.proceed) { return { blocked: true, reason: cutoffDecision.reason || "quota_exhausted" }; diff --git a/open-sse/services/quotaPreflight.ts b/open-sse/services/quotaPreflight.ts index a6c7d99aef..e22d8597b6 100644 --- a/open-sse/services/quotaPreflight.ts +++ b/open-sse/services/quotaPreflight.ts @@ -21,12 +21,22 @@ import { isCompatibleProviderConnectionId } from "@/shared/utils/compatibleProviderId"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { fetchNewApiAggregatorQuota } from "./newApiAggregatorQuotaFetcher.ts"; +import { + isAntigravityQuotaProvider, + selectAntigravityQuotaWindowNames, +} from "./antigravityQuotaFamily.ts"; export interface PreflightQuotaResult { proceed: boolean; reason?: string; quotaPercent?: number; resetAt?: string | null; + windowName?: string | null; +} + +export interface QuotaCutoffScope { + provider?: string | null; + requestedModel?: string | null; } export interface QuotaWindowInfo { @@ -156,15 +166,36 @@ function isRemainingAtOrBelowThreshold( return remainingPercent <= thresholdPercent + REMAINING_PERCENT_EPSILON; } -function exhaustedResult(quotaPercent: number, resetAt: string | null): PreflightQuotaResult { +function exhaustedResult( + quotaPercent: number, + resetAt: string | null, + windowName?: string | null +): PreflightQuotaResult { return { proceed: false, reason: "quota_exhausted", quotaPercent, resetAt, + windowName: windowName ?? null, }; } +function windowsForScope( + windows: NonNullable, + scope?: QuotaCutoffScope +): NonNullable { + if (!scope?.requestedModel || !isAntigravityQuotaProvider(scope.provider ?? null)) { + return windows; + } + const selected = selectAntigravityQuotaWindowNames(Object.keys(windows), scope.requestedModel); + if (selected.length === 0) return windows; + const scoped: NonNullable = {}; + for (const name of selected) { + if (windows[name]) scoped[name] = windows[name]; + } + return Object.keys(scoped).length > 0 ? scoped : windows; +} + function limitReachedResult(quota: QuotaInfo): PreflightQuotaResult { return exhaustedResult( Number.isFinite(quota.percentUsed) ? quota.percentUsed : 1, @@ -201,7 +232,9 @@ function quotaWindowCutoffResult( worstResetAt = windowInfo.resetAt ?? null; } - return worstWindow === null ? null : exhaustedResult(worstUsedPercent, worstResetAt); + return worstWindow === null + ? null + : exhaustedResult(worstUsedPercent, worstResetAt, worstWindow); } function quotaPercentCutoffResult( @@ -227,21 +260,27 @@ function quotaPercentCutoffResult( */ export function evaluateQuotaCutoff( quota: QuotaInfo | null | undefined, - thresholds?: PreflightQuotaThresholds + thresholds?: PreflightQuotaThresholds, + scope?: QuotaCutoffScope ): PreflightQuotaResult { if (!quota) return { proceed: true }; - if (quota.limitReached === true) return limitReachedResult(quota); const windows = quota.windows; if (windows && Object.keys(windows).length > 0) { - return ( - quotaWindowCutoffResult(windows, thresholds) ?? { - proceed: true, - quotaPercent: quota.percentUsed, - } - ); + const scopedWindows = windowsForScope(windows, scope); + const cutoff = quotaWindowCutoffResult(scopedWindows, thresholds); + if (cutoff) return cutoff; + if (isAntigravityQuotaProvider(scope?.provider ?? null) && scope?.requestedModel) { + return { proceed: true, quotaPercent: quota.percentUsed }; + } + if (quota.limitReached === true) return limitReachedResult(quota); + return { + proceed: true, + quotaPercent: quota.percentUsed, + }; } + if (quota.limitReached === true) return limitReachedResult(quota); return quotaPercentCutoffResult(quota, thresholds); } @@ -297,61 +336,40 @@ export async function preflightQuota( return { proceed: true }; } - if (quota.limitReached === true) { - return limitReachedResult(quota); - } - - // Per-window evaluation — only when the fetcher surfaces a windows map. - // We block as soon as ANY single window's remaining quota drops to its - // configured cutoff or below; warnings are logged independently per window. - if (quota.windows && Object.keys(quota.windows).length > 0) { - let worstUsedPercent = 0; - let worstWindow: string | null = null; - let worstResetAt: string | null = null; - for (const [windowName, windowInfo] of Object.entries(quota.windows)) { - const minRemainingPercent = resolveOrDefault( - thresholds?.resolveMinRemainingPercent, - windowName, - DEFAULT_MIN_REMAINING_PERCENT - ); + const requestedModel = + typeof connection.requestedModel === "string" ? connection.requestedModel : null; + const scope: QuotaCutoffScope = { provider, requestedModel }; + const windows = quota.windows; + if (windows && Object.keys(windows).length > 0) { + const scopedWindows = windowsForScope(windows, scope); + for (const [windowName, windowInfo] of Object.entries(scopedWindows)) { const warnRemainingPercent = resolveOrDefault( thresholds?.resolveWarnRemainingPercent, windowName, DEFAULT_WARN_REMAINING_PERCENT ); const remainingPercent = remainingPercentFrom(windowInfo.percentUsed); - - if (isRemainingAtOrBelowThreshold(remainingPercent, minRemainingPercent)) { - // Track the most-depleted blocking window so the response can name it. - if (windowInfo.percentUsed > worstUsedPercent) { - worstUsedPercent = windowInfo.percentUsed; - worstWindow = windowName; - worstResetAt = windowInfo.resetAt ?? null; - } else if (worstWindow === null) { - worstWindow = windowName; - worstResetAt = windowInfo.resetAt ?? null; - } - } else if (isRemainingAtOrBelowThreshold(remainingPercent, warnRemainingPercent)) { + if (isRemainingAtOrBelowThreshold(remainingPercent, warnRemainingPercent)) { console.warn( `[QuotaPreflight] ${provider}/${connectionId} ${windowName}: ${remainingPercent.toFixed(1)}% remaining — approaching cutoff` ); } } + } - if (worstWindow !== null) { - const worstRemaining = remainingPercentFrom(worstUsedPercent); - console.info( - `[QuotaPreflight] ${provider}/${connectionId} ${worstWindow}: ${worstRemaining.toFixed(1)}% remaining — switching` - ); - return { - proceed: false, - reason: "quota_exhausted", - quotaPercent: worstUsedPercent, - resetAt: worstResetAt, - }; - } - - return { proceed: true, quotaPercent: quota.percentUsed }; + const decision = evaluateQuotaCutoff(quota, thresholds, scope); + if (!decision.proceed) { + const windowLabel = decision.windowName ? ` ${decision.windowName}` : ""; + const remaining = Number.isFinite(decision.quotaPercent) + ? remainingPercentFrom(decision.quotaPercent as number).toFixed(1) + : "?"; + console.info( + `[QuotaPreflight] ${provider}/${connectionId}${windowLabel}: ${remaining}% remaining - switching` + ); + return decision; + } + if (windows && Object.keys(windows).length > 0) { + return decision; } // Legacy single-signal path for fetchers that don't expose per-window data. diff --git a/src/domain/quotaCache.ts b/src/domain/quotaCache.ts index 377512ee13..0065eb61ea 100644 --- a/src/domain/quotaCache.ts +++ b/src/domain/quotaCache.ts @@ -38,7 +38,7 @@ import { resolveCodexAccount, type CodexPersistedQuotaState, } from "@omniroute/open-sse/services/codexAccount/index.ts"; -import { getAntigravityQuotaFamily } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; +import { selectAntigravityQuotaWindowNames } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -273,37 +273,7 @@ function resolveAntigravityQuotaWindowsForModel( quotaNames: string[], requestedModel: string ): string[] { - const requestedFamily = getAntigravityQuotaFamily(requestedModel); - const cleanRequestedModel = requestedModel.replace(/^(antigravity|agy)\//, ""); - const bareModel = cleanRequestedModel.includes("/") - ? cleanRequestedModel.slice(cleanRequestedModel.lastIndexOf("/") + 1) - : cleanRequestedModel; - - if (requestedFamily === "other") { - return quotaNames.filter((windowName) => { - const bare = windowName.replace(/^(antigravity|agy)\//, ""); - return bare === bareModel || bare === cleanRequestedModel; - }); - } - - const familyAggregates = - requestedFamily === "gemini" - ? ["gemini_weekly"] - : requestedFamily === "claude" - ? ["claude_gpt_weekly"] - : []; - - const exactWindows = quotaNames.filter((windowName) => { - const bare = windowName.replace(/^(antigravity|agy)\//, ""); - return bare === bareModel; - }); - const aggregateWindows = familyAggregates.filter((key) => quotaNames.includes(key)); - const scoped = [...exactWindows, ...aggregateWindows]; - if (scoped.length > 0) return scoped; - - return quotaNames.filter( - (windowName) => getAntigravityQuotaFamily(windowName) === requestedFamily - ); + return selectAntigravityQuotaWindowNames(quotaNames, requestedModel); } function isAntigravityQuotaExhausted( diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index ec2b42a17d..697c8a036f 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -47,7 +47,12 @@ import { hydrateCodexQuotaCacheForRequest, isQuotaExhaustedForRequest, } from "@/domain/quotaCache"; -import { getQuotaScopeLabelForProvider } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; +import { + getQuotaScopeLabelForProvider, + isAntigravityQuotaProvider, +} from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; +import { rehydrateAntigravityFamilyLocksForConnections, persistAntigravityFamilyCooldownIfQuota } from "@omniroute/open-sse/services/antigravityFamilyCooldown.ts"; +import { markQuotaPreflightAccountUnavailable } from "./quotaPreflightUnavailable.ts"; import { getCreditsMode } from "@omniroute/open-sse/services/antigravityCredits.ts"; import { preferAntigravityConnectionsWithStoredProject } from "@omniroute/open-sse/services/antigravityProjectPersistence.ts"; import { @@ -891,43 +896,6 @@ function buildQuotaPreflightRateLimitedResult( lastErrorCode: 429, }; } -function quotaPreflightUnavailableUntil(resetAt?: string | null): string { - const resetMs = parseFutureDateMs(resetAt ?? null); - return new Date(resetMs ?? Date.now() + 5 * 60 * 1000).toISOString(); -} -async function markQuotaPreflightAccountUnavailable( - provider: string, - connectionId: string, - preflight: { quotaPercent?: number; resetAt?: string | null }, - requestedModel: string | null -): Promise { - const unavailableUntil = quotaPreflightUnavailableUntil(preflight.resetAt ?? null); - if (provider === "codex" && requestedModel?.trim()) { - await persistCodexChildCooldown({ - connectionId, - model: requestedModel, - rateLimitedUntil: unavailableUntil, - }); - return unavailableUntil; - } - - const percentLabel = Number.isFinite(preflight.quotaPercent) - ? `${Math.round((preflight.quotaPercent as number) * 100)}%` - : "exhausted"; - const modelLabel = requestedModel ? ` for ${requestedModel}` : ""; - - await updateProviderConnection(connectionId, { - rateLimitedUntil: unavailableUntil, - testStatus: "unavailable", - lastError: `Quota preflight blocked${modelLabel}: ${percentLabel}`, - lastErrorType: "quota_exhausted", - lastErrorSource: "quota_preflight", - errorCode: 429, - lastErrorAt: new Date().toISOString(), - }); - - return unavailableUntil; -} // Provider-scoped mutexes prevent race conditions during account selection without // serializing unrelated providers behind a single global lock. @@ -1326,6 +1294,7 @@ export async function getProviderCredentials( ); } } + rehydrateAntigravityFamilyLocksForConnections(provider, connections); // allowedConnections: restrict to specific connection IDs (from API key policy, #363) if (allowedConnections && allowedConnections.length > 0) { connections = connections.filter((conn) => allowedConnections.includes(conn.id)); @@ -2405,7 +2374,9 @@ export async function getProviderCredentialsWithQuotaPreflight( return defaultThresholdPercent; }; // #6842: openrouter also needs requestedModel, for the :free-window check. - const modelAwarePreflight = provider === "codex" || provider === "openrouter"; + // agy/antigravity need it so Claude weekly cannot cool a Gemini request. + const modelAwarePreflight = + provider === "codex" || provider === "openrouter" || isAntigravityQuotaProvider(provider); const preflightCredentials = requestedModel && modelAwarePreflight ? { ...credentials, requestedModel } : credentials; let preflight; @@ -2967,6 +2938,7 @@ export async function markAccountUnavailable( "AUTH", `Model-only lockout for ${provider}:${model} — ${status} ${reason} ${Math.ceil(lockout.cooldownMs / 1000)}s (failureCount=${lockout.failureCount}, connection stays active)` ); + persistAntigravityFamilyCooldownIfQuota({ provider, connectionId, model, cooldownMs: lockout.cooldownMs, reason }); return { shouldFallback: true, cooldownMs: lockout.cooldownMs }; } const result = fallbackResult; diff --git a/src/sse/services/quotaPreflightUnavailable.ts b/src/sse/services/quotaPreflightUnavailable.ts new file mode 100644 index 0000000000..f1ac373a50 --- /dev/null +++ b/src/sse/services/quotaPreflightUnavailable.ts @@ -0,0 +1,61 @@ +import { persistCodexChildCooldown } from "@omniroute/open-sse/services/codexAccount/index.ts"; +import { persistAntigravityPreflightFamilyLock } from "@omniroute/open-sse/services/antigravityFamilyCooldown.ts"; +import { isAntigravityQuotaProvider } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; +import { cooldownUntilMs } from "@omniroute/open-sse/services/accountFallback.ts"; +import { updateProviderConnection } from "@/lib/db/providers"; + +function parseFutureDateMs(value: string | null): number | null { + if (!value) return null; + const ms = cooldownUntilMs(value); + if (!Number.isFinite(ms) || ms <= Date.now()) return null; + return ms; +} + +function quotaPreflightUnavailableUntil(resetAt?: string | null): string { + const resetMs = parseFutureDateMs(resetAt ?? null); + return new Date(resetMs ?? Date.now() + 5 * 60 * 1000).toISOString(); +} + +export async function markQuotaPreflightAccountUnavailable( + provider: string, + connectionId: string, + preflight: { quotaPercent?: number; resetAt?: string | null }, + requestedModel: string | null +): Promise { + const unavailableUntil = quotaPreflightUnavailableUntil(preflight.resetAt ?? null); + if (provider === "codex" && requestedModel?.trim()) { + await persistCodexChildCooldown({ + connectionId, + model: requestedModel, + rateLimitedUntil: unavailableUntil, + }); + return unavailableUntil; + } + + if (isAntigravityQuotaProvider(provider) && requestedModel?.trim()) { + await persistAntigravityPreflightFamilyLock({ + provider, + connectionId, + model: requestedModel, + unavailableUntil, + }); + return unavailableUntil; + } + + const percentLabel = Number.isFinite(preflight.quotaPercent) + ? `${Math.round((preflight.quotaPercent as number) * 100)}%` + : "exhausted"; + const modelLabel = requestedModel ? ` for ${requestedModel}` : ""; + + await updateProviderConnection(connectionId, { + rateLimitedUntil: unavailableUntil, + testStatus: "unavailable", + lastError: `Quota preflight blocked${modelLabel}: ${percentLabel}`, + lastErrorType: "quota_exhausted", + lastErrorSource: "quota_preflight", + errorCode: 429, + lastErrorAt: new Date().toISOString(), + }); + + return unavailableUntil; +} diff --git a/stryker.conf.json b/stryker.conf.json index 23dd4fb2d5..58dd1f4d4f 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -67,6 +67,7 @@ "tests/unit/agentrouter-lock-scope-10334.test.ts", "tests/unit/alibaba-free-tier-exhaustion.test.ts", "tests/unit/anthropic-thinking-signature-recovery.test.ts", + "tests/unit/agy-family-not-connection-cooldown.test.ts", "tests/unit/antigravity-429-quota-cooldown.test.ts", "tests/unit/antigravity-429-quota-tdd.test.ts", "tests/unit/antigravity-prefer-stored-project.test.ts", diff --git a/tests/unit/agy-family-not-connection-cooldown.test.ts b/tests/unit/agy-family-not-connection-cooldown.test.ts new file mode 100644 index 0000000000..9c4499e8cb --- /dev/null +++ b/tests/unit/agy-family-not-connection-cooldown.test.ts @@ -0,0 +1,258 @@ +/** + * Claude weekly exhaustion must not cool the whole agy/antigravity connection. + * Gemini on the same account stays routable; only family:claude is locked. + */ +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-agy-family-cd-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "agy-family-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const quotaPreflight = await import("../../open-sse/services/quotaPreflight.ts"); +const family = await import("../../open-sse/services/antigravityQuotaFamily.ts"); +const fallback = await import("../../open-sse/services/accountFallback.ts"); +const { markConnectionQuotaExhausted } = await import("../../open-sse/executors/antigravity.ts"); +const { quotaRemainingPercentFromQuota } = await import( + "../../open-sse/services/combo/comboPredicates.ts" +); + +const CLAUDE_RESET = "2026-09-06T17:38:10.000Z"; +const GEMINI_RESET = "2026-09-09T09:59:00.000Z"; + +function mixedWindows() { + return { + claude_gpt_weekly: { percentUsed: 1, resetAt: CLAUDE_RESET }, + gemini_weekly: { percentUsed: 0.022, resetAt: GEMINI_RESET }, + "gemini-3.1-flash-lite": { percentUsed: 0.1, resetAt: null }, + }; +} + +test.after(() => { + fallback.clearAllModelLockouts(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("selectAntigravityQuotaWindowNames keeps Claude weekly off a Gemini request", () => { + const names = family.selectAntigravityQuotaWindowNames(Object.keys(mixedWindows()), "gemini-3.1-flash-lite"); + assert.deepEqual(names.sort(), ["gemini-3.1-flash-lite", "gemini_weekly"].sort()); +}); + +test("preflightQuota proceeds on Gemini when only Claude weekly is exhausted", async () => { + quotaPreflight.registerQuotaFetcher("agy", async () => ({ + used: 0, + total: 0, + percentUsed: 1, + limitReached: true, + windows: mixedWindows(), + })); + + const result = await quotaPreflight.preflightQuota("agy", "conn-1", { + requestedModel: "agy/gemini-3.1-flash-lite", + }); + assert.equal(result.proceed, true, "Gemini must not inherit Claude weekly exhaustion"); +}); + +test("preflightQuota blocks Gemini when gemini_weekly is exhausted", async () => { + quotaPreflight.registerQuotaFetcher("agy-gemini-dead", async () => ({ + used: 0, + total: 0, + percentUsed: 0.99, + windows: { + claude_gpt_weekly: { percentUsed: 0.1, resetAt: CLAUDE_RESET }, + gemini_weekly: { percentUsed: 0.99, resetAt: GEMINI_RESET }, + }, + })); + + const result = await quotaPreflight.preflightQuota("agy-gemini-dead", "conn-2", { + requestedModel: "gemini-3.1-flash-lite", + }); + assert.equal(result.proceed, false); + assert.equal(result.windowName, "gemini_weekly"); + assert.equal(result.resetAt, GEMINI_RESET); +}); + +test("evaluateQuotaCutoff with requestedModel ignores the other family window", () => { + const quota = { + used: 0, + total: 0, + percentUsed: 1, + limitReached: true, + windows: mixedWindows(), + }; + const gemini = quotaPreflight.evaluateQuotaCutoff(quota, undefined, { + provider: "antigravity", + requestedModel: "gemini-3.1-flash-lite", + }); + assert.equal(gemini.proceed, true); + + const claude = quotaPreflight.evaluateQuotaCutoff(quota, undefined, { + provider: "agy", + requestedModel: "claude-opus-4-6-thinking", + }); + assert.equal(claude.proceed, false); + assert.equal(claude.windowName, "claude_gpt_weekly"); +}); + +test("quotaRemainingPercentFromQuota for Gemini uses Gemini windows, not Claude", () => { + const quota = { windows: mixedWindows(), percentUsed: 1, limitReached: true }; + const remaining = quotaRemainingPercentFromQuota(quota, { + provider: "agy", + requestedModel: "gemini-3.1-flash-lite", + }); + assert.ok(remaining > 50, `expected Gemini remaining, got ${remaining}`); +}); + +test("markConnectionQuotaExhausted with a Gemini model locks the family, not the row", async () => { + fallback.clearAllModelLockouts(); + const conn = await providersDb.createProviderConnection({ + provider: "agy", + authType: "oauth", + name: "agy-family-gemini", + }); + const connId = (conn as { id: string }).id; + + markConnectionQuotaExhausted(connId, 24 * 60 * 60 * 1000, "gemini-3.1-flash-lite"); + + assert.equal( + providersDb.isConnectionRateLimited(connId), + false, + "connection row must stay selectable for the other family" + ); + assert.equal(fallback.isModelLocked("agy", connId, "gemini-3.1-flash-lite"), true); + assert.equal(fallback.isModelLocked("agy", connId, "gemini-3.7-flash-high"), true); + assert.equal(fallback.isModelLocked("agy", connId, "claude-opus-4-6-thinking"), false); +}); + +test("Antigravity RPM 429 stays exact-model and does not persist a family cooldown", async () => { + fallback.clearAllModelLockouts(); + const auth = await import("../../src/sse/services/auth.ts"); + const conn = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + email: "rpm@example.test", + accessToken: "tok-rpm", + isActive: true, + testStatus: "active", + }); + const connId = (conn as { id: string }).id; + + await auth.markAccountUnavailable( + connId, + 429, + "RESOURCE_EXHAUSTED: Resource has been exhausted (requests per minute / RPM limit was reached)", + "antigravity", + "gemini-3-pro" + ); + + const sibling = await auth.getProviderCredentials("antigravity", null, null, "gemini-2.5-pro"); + assert.ok(sibling && !("allRateLimited" in sibling && sibling.allRateLimited)); + assert.equal(sibling.connectionId, connId); + + const fresh = await providersDb.getProviderConnectionById(connId); + const psd = (fresh as { providerSpecificData?: Record }).providerSpecificData; + assert.equal( + psd && typeof psd === "object" ? psd.antigravityFamilyRateLimitedUntil : undefined, + undefined + ); + await providersDb.updateProviderConnection(connId, { isActive: false }); +}); + +test("Antigravity QPM 429 stays exact-model and does not persist a family cooldown", async () => { + fallback.clearAllModelLockouts(); + const auth = await import("../../src/sse/services/auth.ts"); + const conn = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + email: "qpm@example.test", + accessToken: "tok-qpm", + isActive: true, + testStatus: "active", + }); + const connId = (conn as { id: string }).id; + + await auth.markAccountUnavailable( + connId, + 429, + "RESOURCE_EXHAUSTED: Resource has been exhausted (queries per minute limit was reached)", + "antigravity", + "gemini-3-pro" + ); + + const sibling = await auth.getProviderCredentials("antigravity", null, null, "gemini-2.5-pro"); + assert.ok(sibling && !("allRateLimited" in sibling && sibling.allRateLimited)); + assert.equal(sibling.connectionId, connId); + + const fresh = await providersDb.getProviderConnectionById(connId); + const psd = (fresh as { providerSpecificData?: Record }).providerSpecificData; + assert.equal( + psd && typeof psd === "object" ? psd.antigravityFamilyRateLimitedUntil : undefined, + undefined + ); + await providersDb.updateProviderConnection(connId, { isActive: false }); +}); + +test("persisted family cooldown rehydrates after a process-local lockout wipe", async () => { + fallback.clearAllModelLockouts(); + const conn = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: "ag-family-persist", + }); + const connId = (conn as { id: string }).id; + const until = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + + const { persistAntigravityFamilyCooldown, rehydrateAntigravityFamilyLocks } = await import( + "../../open-sse/services/antigravityFamilyCooldown.ts" + ); + await persistAntigravityFamilyCooldown({ + connectionId: connId, + model: "claude-sonnet-4", + rateLimitedUntil: until, + }); + + fallback.clearAllModelLockouts(); + assert.equal(fallback.isModelLocked("antigravity", connId, "claude-opus-4"), false); + + const fresh = await providersDb.getProviderConnectionById(connId); + rehydrateAntigravityFamilyLocks( + "antigravity", + connId, + (fresh as { providerSpecificData?: Record }).providerSpecificData + ); + assert.equal(fallback.isModelLocked("antigravity", connId, "claude-opus-4"), true); + assert.equal(fallback.isModelLocked("antigravity", connId, "gemini-3.1-flash-lite"), false); + assert.equal(providersDb.isConnectionRateLimited(connId), false); +}); + +test("preflight family lock covers both agy and antigravity spellings", async () => { + fallback.clearAllModelLockouts(); + const { persistAntigravityPreflightFamilyLock } = await import( + "../../open-sse/services/antigravityFamilyCooldown.ts" + ); + const conn = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: "ag-preflight-alias", + }); + const connId = (conn as { id: string }).id; + const until = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + + await persistAntigravityPreflightFamilyLock({ + provider: "antigravity", + connectionId: connId, + model: "claude-sonnet-4", + unavailableUntil: until, + }); + + assert.equal(fallback.isModelLocked("antigravity", connId, "claude-opus-4"), true); + assert.equal(fallback.isModelLocked("agy", connId, "claude-opus-4"), true); + assert.equal(fallback.isModelLocked("antigravity", connId, "gemini-3.1-flash-lite"), false); + assert.equal(fallback.isModelLocked("agy", connId, "gemini-3.1-flash-lite"), false); +}); diff --git a/tests/unit/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts index 22e6954a76..97e713a0c7 100644 --- a/tests/unit/hard-session-lease-bypass-inventory.test.ts +++ b/tests/unit/hard-session-lease-bypass-inventory.test.ts @@ -84,6 +84,8 @@ const EXPECTED: Record> = { "open-sse/handlers/cursorCliProxy.ts": 1, "open-sse/services/alibabaFreeTier.ts": 1, "open-sse/services/alibabaFreeTierQuotaFetcher.ts": 1, + // Family cooldown persist looks the row up to write PSD, not dispatch. + "open-sse/services/antigravityFamilyCooldown.ts": 1, // v3.8.50 back-merge additions (f95b03d7): combo routing infra and the // volcengine-plan binding/auto-sync services query connections the same // way as their classified siblings. From a47d2e521ea9345b9f772ea8dc9a58c13941c026 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Thu, 3 Sep 2026 11:46:06 -0400 Subject: [PATCH 32/47] feat(providers): add SeekAi OpenAI-compatible New-API gateway (#12557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca. O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. --- AGENTS.md | 2 +- README.md | 6 +- changelog.d/features/11786-seekai-provider.md | 1 + config/quality/file-size-baseline.json | 3 +- docs/diagrams/cli-terminal.svg | 2 +- docs/diagrams/comparison-table.svg | 2 +- docs/diagrams/promise-pillars.svg | 6 +- docs/diagrams/readme-hero.svg | 4 +- docs/i18n/ar/llm.txt | 4 +- docs/i18n/az/llm.txt | 4 +- docs/i18n/bg/llm.txt | 4 +- docs/i18n/bn/llm.txt | 4 +- docs/i18n/cs/llm.txt | 4 +- docs/i18n/da/llm.txt | 4 +- docs/i18n/de/llm.txt | 4 +- docs/i18n/es/llm.txt | 4 +- docs/i18n/fa/llm.txt | 4 +- docs/i18n/fi/llm.txt | 4 +- docs/i18n/fr/llm.txt | 4 +- docs/i18n/gu/llm.txt | 4 +- docs/i18n/he/llm.txt | 4 +- docs/i18n/hi/llm.txt | 4 +- docs/i18n/hu/llm.txt | 4 +- docs/i18n/id/llm.txt | 4 +- docs/i18n/it/llm.txt | 4 +- docs/i18n/ja/llm.txt | 4 +- docs/i18n/ko/llm.txt | 4 +- docs/i18n/mr/llm.txt | 4 +- docs/i18n/ms/llm.txt | 4 +- docs/i18n/nl/llm.txt | 4 +- docs/i18n/no/llm.txt | 4 +- docs/i18n/phi/llm.txt | 4 +- docs/i18n/pl/llm.txt | 4 +- docs/i18n/pt-BR/llm.txt | 4 +- docs/i18n/pt/llm.txt | 4 +- docs/i18n/ro/llm.txt | 4 +- docs/i18n/ru/llm.txt | 4 +- docs/i18n/sk/llm.txt | 4 +- docs/i18n/sv/llm.txt | 4 +- docs/i18n/sw/llm.txt | 4 +- docs/i18n/ta/llm.txt | 4 +- docs/i18n/te/llm.txt | 4 +- docs/i18n/th/llm.txt | 4 +- docs/i18n/tr/llm.txt | 4 +- docs/i18n/uk-UA/llm.txt | 4 +- docs/i18n/ur/llm.txt | 4 +- docs/i18n/vi/llm.txt | 4 +- docs/i18n/zh-CN/llm.txt | 4 +- docs/i18n/zh-TW/llm.txt | 4 +- docs/reference/PROVIDER_REFERENCE.md | 9 +-- llm.txt | 4 +- open-sse/config/providers/index.ts | 2 + .../config/providers/registry/seekai/index.ts | 18 +++++ package.json | 2 +- public/images/tier-flow-dark.svg | 6 +- public/images/tier-flow-light.svg | 6 +- src/i18n/messages/ar.json | 1 + src/i18n/messages/az.json | 1 + src/i18n/messages/bg.json | 1 + src/i18n/messages/bn.json | 1 + src/i18n/messages/cs.json | 1 + src/i18n/messages/da.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/fa.json | 1 + src/i18n/messages/fi.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/gu.json | 1 + src/i18n/messages/he.json | 1 + src/i18n/messages/hi.json | 1 + src/i18n/messages/hu.json | 1 + src/i18n/messages/id.json | 1 + src/i18n/messages/it.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/mr.json | 1 + src/i18n/messages/ms.json | 1 + src/i18n/messages/nl.json | 1 + src/i18n/messages/no.json | 1 + src/i18n/messages/phi.json | 1 + src/i18n/messages/pl.json | 1 + src/i18n/messages/pt-BR.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/ro.json | 1 + src/i18n/messages/ru.json | 1 + src/i18n/messages/sk.json | 1 + src/i18n/messages/sv.json | 1 + src/i18n/messages/sw.json | 1 + src/i18n/messages/ta.json | 1 + src/i18n/messages/te.json | 1 + src/i18n/messages/th.json | 1 + src/i18n/messages/tr.json | 1 + src/i18n/messages/uk-UA.json | 1 + src/i18n/messages/ur.json | 1 + src/i18n/messages/vi.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + src/shared/constants/config.ts | 1 + src/shared/constants/providers.ts | 1 + .../constants/providers/apikey/gateways.ts | 20 ++++++ tests/snapshots/provider/translate-path.json | 23 +++++++ .../provider-node-reserved-prefix.test.ts | 3 +- tests/unit/providers-constants-split.test.ts | 13 ++-- tests/unit/seekai-provider.test.ts | 67 +++++++++++++++++++ 105 files changed, 293 insertions(+), 114 deletions(-) create mode 100644 changelog.d/features/11786-seekai-provider.md create mode 100644 open-sse/config/providers/registry/seekai/index.ts create mode 100644 tests/unit/seekai-provider.test.ts diff --git a/AGENTS.md b/AGENTS.md index d0ac2952b2..1e90b6ceed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below. ## Project at a Glance -**OmniRoute** — unified AI proxy/router. One endpoint, 355 LLM providers, auto-fallback. +**OmniRoute** — unified AI proxy/router. One endpoint, 356 LLM providers, auto-fallback. | Layer | Location | Purpose | | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/README.md b/README.md index 055f71a88b..119a590cb3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ # 🚀 OmniRoute — The Free AI Gateway -OmniRoute — Never stop coding. Every AI tool → 355 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 355 AI providers · 150+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start. +OmniRoute — Never stop coding. Every AI tool → 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 356 AI providers · 150+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start.
@@ -209,7 +209,7 @@ curl http://localhost:20128/v1/chat/completions \
-The Promise — One endpoint and 355 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 355 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files. +The Promise — One endpoint and 356 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 356 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files.

@@ -462,7 +462,7 @@ All **19** strategies — mix & match per combo step:
-What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 355 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 42 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. +What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 356 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 42 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. 📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md) diff --git a/changelog.d/features/11786-seekai-provider.md b/changelog.d/features/11786-seekai-provider.md new file mode 100644 index 0000000000..ad4e2dbd44 --- /dev/null +++ b/changelog.d/features/11786-seekai-provider.md @@ -0,0 +1 @@ +- **feat(providers):** add SeekAi (`seekai.cc`) as an OpenAI-compatible New-API gateway — catalog id `seekai` (alias `ska`), `https://seekai.cc/v1`, live `/v1/models` via `passthroughModels`, aggregator-list membership so New-API balance detection can opt in. No referral/aff codes. ([#11786](https://github.com/diegosouzapw/OmniRoute/issues/11786)) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index ffed35fbc1..e5d0287a65 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_09_02_11786_seekai_provider": "PR #11786 (feat/11786-seekai-provider, closes #11786) own growth: src/shared/constants/providers/apikey/gateways.ts 1438->1458 (check-file-size split-newline=1459; the seekai APIKEY_PROVIDERS_GATEWAYS catalog entry plus authHint, additive data at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines: #10987 logfare, #10531 freebuff). Covered by tests/unit/seekai-provider.test.ts.", "_rebaseline_2026_09_02_12325_generic_429_invalidate": "PR #12325 own growth: open-sse/handlers/chatCore.ts 5946->5955 (+9 = the non-Codex 429 else-if that drops the generic quota wrapper and stamps force-refresh, plus a source-regex breadcrumb). Irreducible call-site wiring next to the existing Codex 429 invalidateCodexQuotaCache branch; not extractable without splitting handleChatCore mid-response. Covered by tests/unit/generic-quota-fetcher.test.ts (31/31) and tests/unit/antigravity-429-quota-cooldown.test.ts.", "_rebaseline_2026_09_02_12429_wreq_migration_suite": "PR #12429 (wreq-js web-cookie transport): new test file tests/unit/tls-client-wreq-migration.test.ts at 1374 lines, above the 1200 new-file testCap. Frozen rather than split: it is the single cohesive regression suite for the transport migration (31 cases covering streaming, fragmented EOF sentinels, proxy isolation, first-byte and hard deadlines, binary responses and cancellation), and the cases share the native-transport harness the file sets up once. Splitting it during a merge would duplicate that harness across files for no coverage gain. Entered at the exact LOC, so it can only ratchet down from here.", "_rebaseline_2026_09_02_12239_chatgpt_web_cleanroom": "PR #12239 (backryun, codex/restore-chatgpt-web-cleanroom) own growth at the two existing chat chokepoints for the clean-room ChatGPT Web transport: src/sse/handlers/chat.ts 2384->2424 (+40); open-sse/handlers/chatCore.ts 5946->5976 (+30). Additive dispatch wiring; the retirement guard is narrowed to the GPL-derived cgpt-web alias rather than removed, so #11754's provenance decision still holds for the old implementation. Same own-growth rationale as _rebaseline_2026_08_20_10531_freebuff_provider.", @@ -450,7 +451,7 @@ "src/lib/tailscaleTunnel.ts": 1208, "src/lib/tokenHealthCheck.ts": 1218, "src/shared/components/RequestLoggerV2.tsx": 1718, - "src/shared/constants/providers/apikey/gateways.ts": 1439, + "src/shared/constants/providers/apikey/gateways.ts": 1459, "src/shared/services/cliRuntime.ts": 1296, "src/sse/handlers/chat.ts": 2424, "src/sse/services/auth.ts": 3427, diff --git a/docs/diagrams/cli-terminal.svg b/docs/diagrams/cli-terminal.svg index 139a868898..a16f88351f 100644 --- a/docs/diagrams/cli-terminal.svg +++ b/docs/diagrams/cli-terminal.svg @@ -1,4 +1,4 @@ - + Compact animated terminal cycling three real OmniRoute CLI commands with a typewriter effect and a scrolling subcommand ticker; the first frame shows the completed providers-list screen. diff --git a/docs/diagrams/comparison-table.svg b/docs/diagrams/comparison-table.svg index 3bc3895f25..92fe11718c 100644 --- a/docs/diagrams/comparison-table.svg +++ b/docs/diagrams/comparison-table.svg @@ -1,4 +1,4 @@ - + Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses. diff --git a/docs/diagrams/promise-pillars.svg b/docs/diagrams/promise-pillars.svg index f32198f62f..41bcdf397c 100644 --- a/docs/diagrams/promise-pillars.svg +++ b/docs/diagrams/promise-pillars.svg @@ -1,4 +1,4 @@ - + Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle. @@ -21,7 +21,7 @@ - One endpoint. 355 providers. Never stop building — OmniRoute picks the cheapest one that works. + One endpoint. 356 providers. Never stop building — OmniRoute picks the cheapest one that works. @@ -38,7 +38,7 @@ Never hit limits - Auto-fallback across 355 providers in + Auto-fallback across 356 providers in milliseconds. Quota out? The next provider takes over while a healthy target remains. diff --git a/docs/diagrams/readme-hero.svg b/docs/diagrams/readme-hero.svg index b758959878..2fc0a31918 100644 --- a/docs/diagrams/readme-hero.svg +++ b/docs/diagrams/readme-hero.svg @@ -1,4 +1,4 @@ - + Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame. @@ -28,7 +28,7 @@ Never stop coding. - Every AI tool → 355 providers150+ free — through one endpoint. + Every AI tool → 356 providers150+ free — through one endpoint. Claude Code · Codex · Cursor · Cline · Copilot · Antigravity  →  FREE Claude / GPT / Gemini · auto-fallback diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt index abd2becf64..603773977a 100644 --- a/docs/i18n/ar/llm.txt +++ b/docs/i18n/ar/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt index 83aae20b40..3af7361352 100644 --- a/docs/i18n/az/llm.txt +++ b/docs/i18n/az/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt index 19b6eaa3bc..c510c85d7e 100644 --- a/docs/i18n/bg/llm.txt +++ b/docs/i18n/bg/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt index ded0a119f8..54b9ca1d9a 100644 --- a/docs/i18n/bn/llm.txt +++ b/docs/i18n/bn/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt index d7d495ce79..4dd9012d51 100644 --- a/docs/i18n/cs/llm.txt +++ b/docs/i18n/cs/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt index fc0f3956f9..d29af5f81c 100644 --- a/docs/i18n/da/llm.txt +++ b/docs/i18n/da/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt index 0a3d6f41df..0002d85510 100644 --- a/docs/i18n/de/llm.txt +++ b/docs/i18n/de/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt index a14c3364ca..c423fdd245 100644 --- a/docs/i18n/es/llm.txt +++ b/docs/i18n/es/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt index 2637cb93e6..d8b83418ea 100644 --- a/docs/i18n/fa/llm.txt +++ b/docs/i18n/fa/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt index dcdbce4e9b..b91777ac80 100644 --- a/docs/i18n/fi/llm.txt +++ b/docs/i18n/fi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt index 4ec20698a0..7e396ec1e9 100644 --- a/docs/i18n/fr/llm.txt +++ b/docs/i18n/fr/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt index bb96a9ca15..75561762bf 100644 --- a/docs/i18n/gu/llm.txt +++ b/docs/i18n/gu/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt index 7b05f7da36..e8dab2b860 100644 --- a/docs/i18n/he/llm.txt +++ b/docs/i18n/he/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt index d5abf815fd..3df2c700b6 100644 --- a/docs/i18n/hi/llm.txt +++ b/docs/i18n/hi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt index 242f219733..bd0667630c 100644 --- a/docs/i18n/hu/llm.txt +++ b/docs/i18n/hu/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt index 6ea1e4a22c..0cce4bc272 100644 --- a/docs/i18n/id/llm.txt +++ b/docs/i18n/id/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt index d3d4caf7c0..0763f0b728 100644 --- a/docs/i18n/it/llm.txt +++ b/docs/i18n/it/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt index fbd4266e07..3dbb124940 100644 --- a/docs/i18n/ja/llm.txt +++ b/docs/i18n/ja/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt index d72d39d120..0d44a5b222 100644 --- a/docs/i18n/ko/llm.txt +++ b/docs/i18n/ko/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt index d3d06a534b..9410d228f5 100644 --- a/docs/i18n/mr/llm.txt +++ b/docs/i18n/mr/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt index fb92e9f704..e610a66e29 100644 --- a/docs/i18n/ms/llm.txt +++ b/docs/i18n/ms/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt index 108465e6ee..a4364f5ac2 100644 --- a/docs/i18n/nl/llm.txt +++ b/docs/i18n/nl/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt index 92ef34cc68..7daa5caa07 100644 --- a/docs/i18n/no/llm.txt +++ b/docs/i18n/no/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt index ed4c534f3b..208e4a1398 100644 --- a/docs/i18n/phi/llm.txt +++ b/docs/i18n/phi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt index 9c2d022df6..79f3f32583 100644 --- a/docs/i18n/pl/llm.txt +++ b/docs/i18n/pl/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt index 1d6c34ce26..e795080292 100644 --- a/docs/i18n/pt-BR/llm.txt +++ b/docs/i18n/pt-BR/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt index 5be880273e..ffa62b14e6 100644 --- a/docs/i18n/pt/llm.txt +++ b/docs/i18n/pt/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt index a9006002d2..7a6f5067a9 100644 --- a/docs/i18n/ro/llm.txt +++ b/docs/i18n/ro/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt index fb3bd9901f..ed8415886d 100644 --- a/docs/i18n/ru/llm.txt +++ b/docs/i18n/ru/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt index 30a439beb7..11385eade3 100644 --- a/docs/i18n/sk/llm.txt +++ b/docs/i18n/sk/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt index a3e29a0308..5a0f5b5c80 100644 --- a/docs/i18n/sv/llm.txt +++ b/docs/i18n/sv/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt index 3e50fc5415..ef792bc7e7 100644 --- a/docs/i18n/sw/llm.txt +++ b/docs/i18n/sw/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt index 26cc42048a..2c0694b490 100644 --- a/docs/i18n/ta/llm.txt +++ b/docs/i18n/ta/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt index 95b13b644a..29ff10874a 100644 --- a/docs/i18n/te/llm.txt +++ b/docs/i18n/te/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt index 0c144efb4f..4096423b6d 100644 --- a/docs/i18n/th/llm.txt +++ b/docs/i18n/th/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt index 9c5ce3e607..b83be6d908 100644 --- a/docs/i18n/tr/llm.txt +++ b/docs/i18n/tr/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt index 407ee0fdfa..f3cf580495 100644 --- a/docs/i18n/uk-UA/llm.txt +++ b/docs/i18n/uk-UA/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt index b80c1a22cc..9efbef2f56 100644 --- a/docs/i18n/ur/llm.txt +++ b/docs/i18n/ur/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt index 8aaea70f4e..eef12beee4 100644 --- a/docs/i18n/vi/llm.txt +++ b/docs/i18n/vi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt index 2a9812d5f2..f1c4a2b574 100644 --- a/docs/i18n/zh-CN/llm.txt +++ b/docs/i18n/zh-CN/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt index 2caf3c753c..b7b1342bf5 100644 --- a/docs/i18n/zh-TW/llm.txt +++ b/docs/i18n/zh-TW/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 67046599ad..c7ba75f601 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,16 +1,16 @@ --- title: "Provider Reference" version: 3.8.51 -lastUpdated: 2026-09-02 +lastUpdated: 2026-09-03 --- # Provider Reference > **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand. > Regenerate with: `npm run gen:provider-reference` -> **Last generated:** 2026-09-02 +> **Last generated:** 2026-09-03 -Total providers: **355**. See category breakdown below. +Total providers: **356**. See category breakdown below. ## Categories @@ -118,7 +118,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `zai-web` | `zw` | Z.ai Web | Web cookie | [link](https://chat.z.ai) | Copy the "token" value from chat.z.ai → DevTools → Application → Local Storage. Do not copy cookies; OmniRoute handles the per-request CAPTCHA through its browser transport. | — | | `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | — | -## API Key Providers (paid / paid-with-free-credits) (237) +## API Key Providers (paid / paid-with-free-credits) (238) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -310,6 +310,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `sarvam` | `sarvam` | Sarvam AI | API key | [link](https://docs.sarvam.ai) | ₹1,000 in free signup credits — never expire | | `scaleway` | `scw` | Scaleway AI | API key | [link](https://www.scaleway.com/en/docs/ai-data/generative-apis/) | 1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B | | `sealion` | `sealion` | SEA-LION | API key | [link](https://sea-lion.ai) | Sign in at sea-lion.ai with Google (no card, no region wall), create an API key, then paste it here. | +| `seekai` | `ska` | SeekAi | API key, aggregator | [link](https://seekai.cc) | Create an API key at https://seekai.cc, then paste it here as a Bearer token. | | `segmind` | `segmind` | Segmind | API key, image, video | [link](https://segmind.com) | Use your Segmind API key in the x-api-key header. OmniRoute targets https://api.segmind.com/v1/ and returns the generated image/video bytes directly. | | `sensenova` | `sensenova` | SenseNova | API key | [link](https://platform.sensenova.cn) | Get API key at platform.sensenova.cn | | `siliconflow` | `siliconflow` | SiliconFlow | API key | [link](https://cloud.siliconflow.com) | $1 free credits plus currently listed $0 models after identity verification; availability and limits may change | diff --git a/llm.txt b/llm.txt index 9789ad5c22..13d3c28e78 100644 --- a/llm.txt +++ b/llm.txt @@ -1,6 +1,6 @@ # OmniRoute -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -277,7 +277,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 1e6b542d13..e6ee28c65f 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -270,6 +270,7 @@ import { voidAiProvider } from "./registry/void-ai/index.ts"; import { helixmindProvider } from "./registry/helixmind/index.ts"; import { tabitokenProvider } from "./registry/tabitoken/index.ts"; import { logfareProvider } from "./registry/logfare/index.ts"; +import { seekaiProvider } from "./registry/seekai/index.ts"; export const REGISTRY: Record = { aimlapi: aimlapiProvider, @@ -544,4 +545,5 @@ export const REGISTRY: Record = { helixmind: helixmindProvider, tabitoken: tabitokenProvider, logfare: logfareProvider, + seekai: seekaiProvider, }; diff --git a/open-sse/config/providers/registry/seekai/index.ts b/open-sse/config/providers/registry/seekai/index.ts new file mode 100644 index 0000000000..e37550cc52 --- /dev/null +++ b/open-sse/config/providers/registry/seekai/index.ts @@ -0,0 +1,18 @@ +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; + +/** + * SeekAi (https://seekai.cc) — QuantumNous New-API gateway. + * Live-verified 2026-09-02: GET /api/status → system_name=SeekAi, + * version=v1.0.0-rc.25, quota_display_type=USD. GET /v1/models is + * API-key gated (401 Invalid token without a key). Catalog is dynamic; + * no static seed. Referral/aff query params stay out of this entry + * (no-hardcoded-referral-codes). + */ +export const seekaiProvider = buildOpenAiCompatibleRegistryEntry({ + id: "seekai", + alias: "ska", + baseUrl: "https://seekai.cc/v1/chat/completions", + modelsUrl: "https://seekai.cc/v1/models", + models: [], + passthroughModels: true, +}); diff --git a/package.json b/package.json index bc63455358..4925d88658 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omniroute", "version": "3.8.51", - "description": "Unified AI router with 355 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", + "description": "Unified AI router with 356 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { "omniroute": "bin/omniroute.mjs", diff --git a/public/images/tier-flow-dark.svg b/public/images/tier-flow-dark.svg index 1cf2589812..8f7fcde48f 100644 --- a/public/images/tier-flow-dark.svg +++ b/public/images/tier-flow-dark.svg @@ -1,6 +1,6 @@ - + OmniRoute 4-tier fallback - OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 355 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. + OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 356 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. @@ -15,7 +15,7 @@ OmniRoute 4-tier fallback - Never stop building — automatic zero-config failover across 355 providers + Never stop building — automatic zero-config failover across 356 providers diff --git a/public/images/tier-flow-light.svg b/public/images/tier-flow-light.svg index cd79d47e3b..5ad3a108f7 100644 --- a/public/images/tier-flow-light.svg +++ b/public/images/tier-flow-light.svg @@ -1,6 +1,6 @@ - + OmniRoute 4-tier fallback - OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 355 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. + OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 356 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. @@ -15,7 +15,7 @@ OmniRoute 4-tier fallback - Never stop building — automatic zero-config failover across 355 providers + Never stop building — automatic zero-config failover across 356 providers diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index b987620b46..a2d2c4acf9 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -6239,6 +6239,7 @@ "requesty": "أنشئ مفتاح API على https://app.requesty.ai، ثم الصقه هنا كرمز Bearer. نقطة نهاية متوافقة مع OpenAI على https://router.requesty.ai/v1، مع كتالوج /v1/models مباشر.", "runwayml": "يعتمد توليد الفيديو في Runway على المهام. يرسل OmniRoute وظائف تحويل النص إلى فيديو أو الصورة إلى فيديو، ويستعلم من /v1/tasks/[id]، ويقوم بتطبيع مخرجات الفيديو النهائية مرة أخرى إلى استجابة /v1/videos/generations الشبيهة بـ OpenAI.", "sambanova": "رصيد مجاني بقيمة 5$ عند التسجيل (صلاحية 30 يومًا)، لا يتطلب بطاقة ائتمان", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "يستخدم اكتشاف النماذج /v2/lm/scenarios/foundation-models/models على AI_API_URL. تستخدم طلبات الدردشة deploymentUrl/chat/completions وتتطلب AI-Resource-Group.", "sarvam": "سارفام AI متوافق مع OpenAI على /v1. يقوم OmniRoute بفحص /v1/models ويوجه حركة الدردشة إلى /v1/chat/completions. تم ضبط النماذج للغات الهندية.", "scaleway": "1 مليون رمز مميز مجاني للحسابات الجديدة — متوافق مع الاتحاد الأوروبي/GDPR (باريس)، Qwen3 235B وLlama 70B", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 33217b0d2b..131a9b212b 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -6239,6 +6239,7 @@ "requesty": "https://app.requesty.ai ünvanında API açarı yaradın, sonra onu bura Bearer tokeni kimi yapışdırın. OpenAI ilə uyğun son nöqtə canlı /v1/models kataloqu ilə https://router.requesty.ai/v1 ünvanındadır.", "runwayml": "Runway video yaradılması tapşırıq əsaslıdır. OmniRoute mətndən-videoya və ya şəkildən-videoya tapşırıqlarını təqdim edir, /v1/tasks/[id] ünvanını sorğulayır və tamamlanmış video çıxışlarını yenidən OpenAI tipli /v1/videos/generations cavabına normallaşdırır.", "sambanova": "Qeydiyyatdan keçdikdə $5 pulsuz kredit (30 gün etibarlılıq müddəti), kredit kartı tələb olunmur", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Model kəşfi AI_API_URL üzərində /v2/lm/scenarios/foundation-models/models istifadə edir. Söhbət sorğuları deploymentUrl/chat/completions istifadə edir və AI-Resource-Group tələb edir.", "sarvam": "Sarvam AI OpenAI ilə uyğun gəlir /v1. OmniRoute /v1/models-i yoxlayır və söhbət trafikini /v1/chat/completions-a yönləndirir. Modellər Hind dilləri üçün tənzimlənmişdir.", "scaleway": "Yeni hesablar üçün 1M pulsuz token — Aİ/GDPR uyğun (Paris), Qwen3 235B və Llama 70B", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 3c3c942e6d..f51cea18c1 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -6239,6 +6239,7 @@ "requesty": "Създайте API ключ на https://app.requesty.ai, след което го поставете тук като Bearer токен. Съвместима с OpenAI крайна точка на https://router.requesty.ai/v1 с каталог на живо за /v1/models.", "runwayml": "Генерирането на видео в Runway е базирано на задачи. OmniRoute изпраща задачи за text-to-video или image-to-video, проверява периодично /v1/tasks/[id] и нормализира готовите видео резултати обратно в наподобяващ OpenAI отговор на /v1/videos/generations.", "sambanova": "$5 безплатни кредити при регистрация (валидност 30 дни), не се изисква кредитна карта", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Откриването на модели използва /v2/lm/scenarios/foundation-models/models на AI_API_URL. Заявките за чат използват deploymentUrl/chat/completions и изискват AI-Resource-Group.", "sarvam": "Sarvam AI е съвместим с OpenAI на /v1. OmniRoute проучва /v1/models и маршрутизира чат трафика към /v1/chat/completions. Моделите са настроени за индийски езици.", "scaleway": "1M безплатни токена за нови акаунти — съвместимо с EU/GDPR (Париж), Qwen3 235B и Llama 70B", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 36cee7c2d9..4dacb5dd56 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -6239,6 +6239,7 @@ "requesty": "https://app.requesty.ai-এ একটি API কী তৈরি করুন, তারপর এটি এখানে Bearer টোকেন হিসেবে পেস্ট করুন। https://router.requesty.ai/v1-এ OpenAI-সামঞ্জস্যপূর্ণ এন্ডপয়েন্ট, সাথে একটি লাইভ /v1/models ক্যাটালগ রয়েছে।", "runwayml": "Runway ভিডিও জেনারেশন টাস্ক-ভিত্তিক। OmniRoute টেক্সট-টু-ভিডিও বা ইমেজ-টু-ভিডিও জব সাবমিট করে, /v1/tasks/[id] পোল করে এবং সমাপ্ত ভিডিও আউটপুটগুলোকে আবার OpenAI-এর মতো /v1/videos/generations রেসপন্সে নরমালাইজ করে।", "sambanova": "সাইন আপ করার সময় $5 ফ্রি ক্রেডিট (৩০ দিনের মেয়াদ), কোনো ক্রেডিট কার্ডের প্রয়োজন নেই", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "মডেল ডিসকভারি AI_API_URL-এ /v2/lm/scenarios/foundation-models/models ব্যবহার করে। চ্যাট রিকোয়েস্টগুলো deploymentUrl/chat/completions ব্যবহার করে এবং এর জন্য AI-Resource-Group প্রয়োজন।", "sarvam": "Sarvam AI OpenAI-সঙ্গত /v1-এ। OmniRoute /v1/models-এ প্রোব করে এবং চ্যাট ট্রাফিককে /v1/chat/completions-এ রাউট করে। মডেলগুলি ইন্ডিক ভাষার জন্য টিউন করা হয়েছে।", "scaleway": "নতুন অ্যাকাউন্টের জন্য 1M ফ্রি টোকেন — EU/GDPR কমপ্লায়েন্ট (প্যারিস), Qwen3 235B এবং Llama 70B", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 568a126332..2450daa9ff 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -6239,6 +6239,7 @@ "requesty": "Vytvořte API klíč na adrese https://app.requesty.ai a poté jej vložte sem jako Bearer token. Koncový bod kompatibilní s OpenAI je na adrese https://router.requesty.ai/v1, s živým katalogem /v1/models.", "runwayml": "Generování videa v Runway je založeno na úlohách. OmniRoute odesílá úlohy typu text-na-video nebo obrázek-na-video, dotazuje se na /v1/tasks/[id] a normalizuje hotové video výstupy zpět do odpovědi typu /v1/videos/generations podobné OpenAI.", "sambanova": "Bezplatný kredit 5 $ při registraci (platnost 30 dní), není vyžadována platební karta", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Vyhledávání modelů používá /v2/lm/scenarios/foundation-models/models na AI_API_URL. Požadavky na chat používají deploymentUrl/chat/completions a vyžadují AI-Resource-Group.", "sarvam": "Sarvam AI je kompatibilní s OpenAI na /v1. OmniRoute prozkoumává /v1/models a směruje chatový provoz na /v1/chat/completions. Modely jsou laděny pro indické jazyky.", "scaleway": "1 milion bezplatných tokenů pro nové účty — v souladu s EU/GDPR (Paříž), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 2f19b8d75a..4dd4699f43 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -6239,6 +6239,7 @@ "requesty": "Opret en API-nøgle på https://app.requesty.ai, og indsæt den derefter her som en Bearer-token. OpenAI-kompatibelt slutpunkt på https://router.requesty.ai/v1 med et live /v1/models-katalog.", "runwayml": "Runway-videogenerering er opgavebaseret. OmniRoute indsender tekst-til-video- eller billede-til-video-job, poller /v1/tasks/[id] og normaliserer de færdige videooutput tilbage til det OpenAI-lignende /v1/videos/generations-svar.", "sambanova": "$5 i gratis kredit ved tilmelding (30 dages gyldighed), intet kreditkort påkrævet", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Modelfindelse bruger /v2/lm/scenarios/foundation-models/models på AI_API_URL. Chatanmodninger bruger deploymentUrl/chat/completions og kræver AI-Resource-Group.", "sarvam": "Sarvam AI er OpenAI-kompatibel på /v1. OmniRoute undersøger /v1/models og dirigerer chattrafik til /v1/chat/completions. Modellerne er tilpasset til indiske sprog.", "scaleway": "1M gratis tokens til nye konti — EU/GDPR-kompatibel (Paris), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index b6c9eccefc..0fa6991704 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -6239,6 +6239,7 @@ "requesty": "Erstellen Sie einen API-Schlüssel unter https://app.requesty.ai und fügen Sie ihn hier als Bearer-Token ein. OpenAI-kompatibler Endpunkt unter https://router.requesty.ai/v1 mit einem Live-Katalog unter /v1/models.", "runwayml": "Die Runway-Videogenerierung ist aufgabenbasiert. OmniRoute übermittelt Text-to-Video- oder Image-to-Video-Jobs, fragt /v1/tasks/[id] ab und normalisiert die fertigen Videoausgaben zurück in die OpenAI-ähnliche Antwort von /v1/videos/generations.", "sambanova": "5 $ kostenloses Guthaben bei Registrierung (30 Tage Gültigkeit), keine Kreditkarte erforderlich", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Die Modellerkennung verwendet /v2/lm/scenarios/foundation-models/models auf AI_API_URL. Chat-Anfragen verwenden deploymentUrl/chat/completions und erfordern AI-Resource-Group.", "sarvam": "Sarvam AI ist OpenAI-kompatibel unter /v1. OmniRoute durchsucht /v1/models und leitet den Chat-Verkehr an /v1/chat/completions weiter. Die Modelle sind auf indische Sprachen abgestimmt.", "scaleway": "1 Mio. kostenlose Token für neue Konten — EU-DSGVO-konform (Paris), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ff70e5c7bb..e1b0adcb11 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -6242,6 +6242,7 @@ "requesty": "Create an API key at https://app.requesty.ai, then paste it here as a Bearer token. OpenAI-compatible endpoint at https://router.requesty.ai/v1, with a live /v1/models catalog.", "runwayml": "Runway video generation is task-based. OmniRoute submits text-to-video or image-to-video jobs, polls /v1/tasks/[id], and normalizes the finished video outputs back into the OpenAI-like /v1/videos/generations response.", "sambanova": "$5 free credits on signup (30-day validity), no credit card required", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Model discovery uses /v2/lm/scenarios/foundation-models/models on AI_API_URL. Chat requests use deploymentUrl/chat/completions and require AI-Resource-Group.", "sarvam": "Sarvam AI is OpenAI-compatible on /v1. OmniRoute probes /v1/models and routes chat traffic to /v1/chat/completions. Models are tuned for Indic languages.", "scaleway": "1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 89f8137d05..99f66790a4 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -6239,6 +6239,7 @@ "requesty": "Create an API key at https://app.requesty.ai, then paste it here as a Bearer token. OpenAI-compatible endpoint at https://router.requesty.ai/v1, with a live /v1/models catalog.", "runwayml": "Runway video generation is task-based. OmniRoute submits text-to-video or image-to-video jobs, polls /v1/tasks/[id], and normalizes the finished video outputs back into the OpenAI-like /v1/videos/generations response.", "sambanova": "$5 free credits on signup (30-day validity), no credit card required", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Model discovery uses /v2/lm/scenarios/foundation-models/models on AI_API_URL. Chat requests use deploymentUrl/chat/completions and require AI-Resource-Group.", "sarvam": "Sarvam AI is OpenAI-compatible on /v1. OmniRoute probes /v1/models and routes chat traffic to /v1/chat/completions. Models are tuned for Indic languages.", "scaleway": "1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index fb8fab5f8b..176e89c4d2 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -6239,6 +6239,7 @@ "requesty": "یک کلید API در https://app.requesty.ai بسازید، سپس آن را در اینجا به عنوان توکن Bearer جای‌گذاری کنید. نقطه پایانی سازگار با OpenAI در https://router.requesty.ai/v1، همراه با کاتالوگ زنده /v1/models.", "runwayml": "تولید ویدیو در Runway مبتنی بر وظیفه (task-based) است. OmniRoute کارهای تبدیل متن به ویدیو یا تصویر به ویدیو را ارسال می‌کند، وضعیت /v1/tasks/[id] را بررسی می‌کند و خروجی‌های ویدیوی نهایی را به پاسخ شبیه به OpenAI در /v1/videos/generations تبدیل می‌کند.", "sambanova": "۵ دلار اعتبار رایگان هنگام ثبت‌نام (با اعتبار ۳۰ روزه)، بدون نیاز به کارت اعتباری", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "کشف مدل از /v2/lm/scenarios/foundation-models/models در AI_API_URL استفاده می‌کند. درخواست‌های چت از deploymentUrl/chat/completions استفاده می‌کنند و به AI-Resource-Group نیاز دارند.", "sarvam": "Sarvam AI با OpenAI سازگار است در /v1. OmniRoute به /v1/models دسترسی پیدا می‌کند و ترافیک چت را به /v1/chat/completions هدایت می‌کند. مدل‌ها برای زبان‌های هندی تنظیم شده‌اند.", "scaleway": "۱ میلیون توکن رایگان برای حساب‌های جدید — سازگار با قوانین اتحادیه اروپا/GDPR (پاریس)، Qwen3 235B و Llama 70B", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 75173454c9..8c7c2c9c98 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -6239,6 +6239,7 @@ "requesty": "Luo API-avain osoitteessa https://app.requesty.ai ja liitä se sitten tähän Bearer-tokenina. OpenAI-yhteensopiva päätepiste osoitteessa https://router.requesty.ai/v1 reaaliaikaisella /v1/models-luettelolla.", "runwayml": "Runway-videonluonti on tehtäväpohjaista. OmniRoute lähettää teksti-videoksi- tai kuva-videoksi -töitä, kyselyttää polkua /v1/tasks/[id] ja normalisoi valmiit videotulosteet takaisin OpenAI-tyyliseen /v1/videos/generations-vastaukseen.", "sambanova": "$5 ilmaista saldoa rekisteröitymisen yhteydessä (voimassa 30 päivää), luottokorttia ei vaadita", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Mallien haku käyttää polkua /v2/lm/scenarios/foundation-models/models osoitteessa AI_API_URL. Chat-pyynnöt käyttävät polkua deploymentUrl/chat/completions ja vaativat AI-Resource-Group-otsakkeen.", "sarvam": "Sarvam AI on OpenAI-yhteensopiva /v1:ssä. OmniRoute tutkii /v1/malleja ja ohjaa keskusteluliikennettä /v1/chat/completions:iin. Mallit on säädetty indialaisille kielille.", "scaleway": "1M ilmaista tokenia uusille tileille — EU/GDPR-yhteensopiva (Pariisi), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index e3d9dc6bab..c17cb242a0 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -6239,6 +6239,7 @@ "requesty": "Créez une clé API sur https://app.requesty.ai, puis collez-la ici en tant que jeton Bearer. Point de terminaison compatible OpenAI sur https://router.requesty.ai/v1, avec un catalogue /v1/models en direct.", "runwayml": "La génération de vidéos Runway est basée sur des tâches. OmniRoute soumet des tâches text-to-video ou image-to-video, interroge /v1/tasks/[id] et normalise les sorties vidéo terminées dans la réponse de type OpenAI /v1/videos/generations.", "sambanova": "5 $ de crédits gratuits à l'inscription (validité de 30 jours), aucune carte de crédit requise", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "La découverte de modèles utilise /v2/lm/scenarios/foundation-models/models sur AI_API_URL. Les requêtes de chat utilisent deploymentUrl/chat/completions et nécessitent AI-Resource-Group.", "sarvam": "Sarvam AI est compatible avec OpenAI sur /v1. OmniRoute interroge /v1/models et achemine le trafic de chat vers /v1/chat/completions. Les modèles sont optimisés pour les langues indiennes.", "scaleway": "1M de tokens gratuits pour les nouveaux comptes — conforme UE/RGPD (Paris), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 21a08458e5..00196e5ad8 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -6239,6 +6239,7 @@ "requesty": "https://app.requesty.ai પર API key બનાવો, પછી તેને અહીં Bearer token તરીકે પેસ્ટ કરો. લાઈવ /v1/models કેટલોગ સાથે https://router.requesty.ai/v1 પર OpenAI-સુસંગત એન્ડપોઇન્ટ.", "runwayml": "Runway વીડિયો જનરેશન ટાસ્ક-આધારિત છે. OmniRoute ટેક્સ્ટ-ટુ-વીડિયો અથવા ઇમેજ-ટુ-વીડિયો જોબ્સ સબમિટ કરે છે, /v1/tasks/[id] ને પોલ કરે છે, અને પૂર્ણ થયેલા વીડિયો આઉટપુટને ફરીથી OpenAI જેવા /v1/videos/generations રિસ્પોન્સમાં નોર્મલાઇઝ કરે છે.", "sambanova": "સાઇનઅપ પર $5 મફત ક્રેડિટ્સ (30-દિવસની માન્યતા), કોઈ ક્રેડિટ કાર્ડની જરૂર નથી", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "મોડલ ડિસ્કવરી AI_API_URL પર /v2/lm/scenarios/foundation-models/models નો ઉપયોગ કરે છે. ચેટ વિનંતીઓ deploymentUrl/chat/completions નો ઉપયોગ કરે છે અને તેના માટે AI-Resource-Group જરૂરી છે.", "sarvam": "Sarvam AI OpenAI-સંગત છે /v1. OmniRoute /v1/models ને તપાસે છે અને ચેટ ટ્રાફિકને /v1/chat/completions પર રુટ કરે છે. મોડલ્સ ઇન્ડિક ભાષાઓ માટે ટ્યુન કરવામાં આવ્યા છે.", "scaleway": "નવા એકાઉન્ટ્સ માટે 1M મફત ટોકન્સ — EU/GDPR સુસંગત (પેરિસ), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 5cbdf78924..19d7e96e65 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -6239,6 +6239,7 @@ "requesty": "צור מפתח API בכתובת https://app.requesty.ai, ולאחר מכן הדבק אותו כאן כ-Bearer token. נקודת קצה תואמת OpenAI בכתובת https://router.requesty.ai/v1, עם קטלוג /v1/models חי.", "runwayml": "יצירת וידאו ב-Runway מבוססת משימות. OmniRoute שולח משימות text-to-video או image-to-video, דוגם את /v1/tasks/[id], ומנרמל את פלטי הווידאו המוגמרים בחזרה לתגובה דמוית OpenAI של /v1/videos/generations.", "sambanova": "קרדיט חינם בסך $5 בהרשמה (תוקף ל-30 יום), ללא צורך בכרטיס אשראי", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "גילוי מודלים משתמש ב-/v2/lm/scenarios/foundation-models/models ב-AI_API_URL. בקשות צ'אט משתמשות ב-deploymentUrl/chat/completions ודורשות את AI-Resource-Group.", "sarvam": "Sarvam AI תואם ל-OpenAI ב-/v1. OmniRoute סורק את /v1/models ומנתב את תנועת השיחה ל-/v1/chat/completions. המודלים מותאמים לשפות אינדיות.", "scaleway": "1M טוקנים בחינם לחשבונות חדשים — תואם EU/GDPR (פריז), Qwen3 235B ו-Llama 70B", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 844ea16376..2f9b11ace2 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -6239,6 +6239,7 @@ "requesty": "https://app.requesty.ai पर एक API कुंजी बनाएं, फिर इसे यहाँ Bearer टोकन के रूप में पेस्ट करें। https://router.requesty.ai/v1 पर OpenAI-संगत एंडपॉइंट, एक लाइव /v1/models कैटलॉग के साथ।", "runwayml": "Runway वीडियो जनरेशन टास्क-आधारित है। OmniRoute टेक्स्ट-टू-वीडियो या इमेज-टू-वीडियो जॉब सबमिट करता है, /v1/tasks/[id] को पोल करता है, और तैयार वीडियो आउटपुट को वापस OpenAI जैसे /v1/videos/generations रिस्पॉन्स में सामान्य (normalize) करता है।", "sambanova": "साइनअप पर $5 मुफ्त क्रेडिट (30 दिनों की वैधता), किसी क्रेडिट कार्ड की आवश्यकता नहीं है", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "मॉडल खोज AI_API_URL पर /v2/lm/scenarios/foundation-models/models का उपयोग करती है। चैट अनुरोध deploymentUrl/chat/completions का उपयोग करते हैं और इसके लिए AI-Resource-Group की आवश्यकता होती है।", "sarvam": "Sarvam AI OpenAI के साथ संगत है /v1. OmniRoute /v1/models को प्रॉब करता है और चैट ट्रैफिक को /v1/chat/completions पर रूट करता है। मॉडल्स को इंडिक भाषाओं के लिए ट्यून किया गया है।", "scaleway": "नए खातों के लिए 1M मुफ्त टोकन — EU/GDPR अनुपालन (पेरिस), Qwen3 235B और Llama 70B", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index d5741d4b62..339507ca3c 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -6239,6 +6239,7 @@ "requesty": "Hozzon létre egy API-kulcsot a https://app.requesty.ai oldalon, majd illessze be ide Bearer tokenként. OpenAI-kompatibilis végpont a https://router.requesty.ai/v1 címen, élő /v1/models katalógussal.", "runwayml": "A Runway videógenerálás feladatalapú. Az OmniRoute elküldi a text-to-video vagy image-to-video feladatokat, lekérdezi a /v1/tasks/[id] állapotát, és a kész videókimeneteket visszaalakítja az OpenAI-szerű /v1/videos/generations válasszá.", "sambanova": "$5 ingyenes kredit regisztrációkor (30 napos érvényesség), bankkártya nem szükséges", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "A modellfelderítés a /v2/lm/scenarios/foundation-models/models végpontot használja az AI_API_URL címen. A chat kérések a deploymentUrl/chat/completions végpontot használják, és AI-Resource-Group szükséges hozzájuk.", "sarvam": "A Sarvam AI OpenAI-kompatibilis a /v1-en. Az OmniRoute a /v1/models-t vizsgálja és a chat forgalmat a /v1/chat/completions-re irányítja. A modellek az indiai nyelvekre vannak optimalizálva.", "scaleway": "1M ingyenes token új fiókoknak — EU/GDPR-megfelelő (Párizs), Qwen3 235B és Llama 70B", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 95f4ca816a..bf2f06a265 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -6239,6 +6239,7 @@ "requesty": "Buat kunci API di https://app.requesty.ai, lalu tempel di sini sebagai token Bearer. Endpoint yang kompatibel dengan OpenAI di https://router.requesty.ai/v1, dengan katalog /v1/models langsung.", "runwayml": "Pembuatan video Runway berbasis tugas. OmniRoute mengirimkan pekerjaan text-to-video atau image-to-video, melakukan polling pada /v1/tasks/[id], dan menormalisasi output video yang selesai kembali ke respons /v1/videos/generations yang mirip OpenAI.", "sambanova": "Kredit gratis $5 saat pendaftaran (validitas 30 hari), tidak memerlukan kartu kredit", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Penemuan model menggunakan /v2/lm/scenarios/foundation-models/models pada AI_API_URL. Permintaan chat menggunakan deploymentUrl/chat/completions dan memerlukan AI-Resource-Group.", "sarvam": "Sarvam AI kompatibel dengan OpenAI di /v1. OmniRoute memeriksa /v1/models dan mengarahkan lalu lintas obrolan ke /v1/chat/completions. Model disesuaikan untuk bahasa-bahasa India.", "scaleway": "1 juta token gratis untuk akun baru — patuh EU/GDPR (Paris), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 4b77e9d30c..855d5b3ecb 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -6239,6 +6239,7 @@ "requesty": "Crea una chiave API su https://app.requesty.ai, quindi incollala qui come token Bearer. Endpoint compatibile con OpenAI su https://router.requesty.ai/v1, con un catalogo /v1/models in tempo reale.", "runwayml": "La generazione video di Runway è basata su task. OmniRoute invia lavori text-to-video o image-to-video, interroga /v1/tasks/[id] e normalizza gli output video completati nella risposta simile a OpenAI /v1/videos/generations.", "sambanova": "$5 di crediti gratuiti alla registrazione (validità 30 giorni), nessuna carta di credito richiesta", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "La scoperta dei modelli utilizza /v2/lm/scenarios/foundation-models/models su AI_API_URL. Le richieste di chat utilizzano deploymentUrl/chat/completions e richiedono AI-Resource-Group.", "sarvam": "Sarvam AI è compatibile con OpenAI su /v1. OmniRoute controlla /v1/models e instrada il traffico chat verso /v1/chat/completions. I modelli sono ottimizzati per le lingue indiane.", "scaleway": "1M di token gratuiti per i nuovi account — conforme a UE/GDPR (Parigi), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index ce2d30773a..6e734c226e 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -6239,6 +6239,7 @@ "requesty": "https://app.requesty.aiでAPIキーを作成し、ここにBearerトークンとして貼り付けます。https://router.requesty.ai/v1にあるOpenAI互換のエンドポイントは、有効な/v1/modelsカタログを提供します。", "runwayml": "Runwayの動画生成はタスクベースです。OmniRouteはtext-to-videoまたはimage-to-videoジョブを送信し、/v1/tasks/[id]をポーリングして、完了した動画出力をOpenAI風の/v1/videos/generationsレスポンスに正規化して戻します。", "sambanova": "新規登録時に$5分の無料クレジット(30日間有効)、クレジットカード不要", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "モデルの検出はAI_API_URL上の/v2/lm/scenarios/foundation-models/modelsを使用します。チャットリクエストはdeploymentUrl/chat/completionsを使用し、AI-Resource-Groupが必要です。", "sarvam": "Sarvam AIは/v1でOpenAI互換です。OmniRouteは/v1/modelsをプローブし、チャットトラフィックを/v1/chat/completionsにルーティングします。モデルはインド系言語に調整されています。", "scaleway": "新規アカウント向けに100万無料トークン — EU/GDPR準拠(パリ)、Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index c6a703ccac..fb12ad0c99 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -6239,6 +6239,7 @@ "requesty": "https://app.requesty.ai에서 API 키를 생성한 후 여기에 Bearer 토큰으로 붙여넣으세요. https://router.requesty.ai/v1의 OpenAI 호환 엔드포인트는 실시간 /v1/models 카탈로그를 제공합니다.", "runwayml": "Runway 비디오 생성은 작업 기반입니다. OmniRoute는 텍스트-비디오 또는 이미지-비디오 작업을 제출하고, /v1/tasks/[id]를 폴링하며, 완료된 비디오 출력을 OpenAI 스타일의 /v1/videos/generations 응답으로 정규화합니다.", "sambanova": "가입 시 $5 무료 크레딧 제공(유효기간 30일), 신용카드 불필요", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "모델 검색은 AI_API_URL의 /v2/lm/scenarios/foundation-models/models를 사용합니다. 채팅 요청은 deploymentUrl/chat/completions를 사용하며 AI-Resource-Group이 필요합니다.", "sarvam": "Sarvam AI는 /v1에서 OpenAI와 호환됩니다. OmniRoute는 /v1/models를 탐색하고 채팅 트래픽을 /v1/chat/completions로 라우팅합니다. 모델은 인도 언어에 맞게 조정되었습니다.", "scaleway": "신규 계정 대상 1M 무료 토큰 — EU/GDPR 준수(파리), Qwen3 235B 및 Llama 70B", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index aeeb20b219..8817c1c87f 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -6239,6 +6239,7 @@ "requesty": "https://app.requesty.ai वर API की तयार करा, नंतर ती येथे Bearer टोकन म्हणून पेस्ट करा. https://router.requesty.ai/v1 वर OpenAI-सुसंगत एंडपॉइंट आहे, ज्यामध्ये थेट /v1/models कॅटलॉग उपलब्ध आहे.", "runwayml": "Runway व्हिडिओ निर्मिती ही टास्क-आधारित आहे. OmniRoute हे text-to-video किंवा image-to-video जॉब्स सबमिट करते, /v1/tasks/[id] पोल करते आणि पूर्ण झालेल्या व्हिडिओ आउटपुटला पुन्हा OpenAI-सारख्या /v1/videos/generations प्रतिसादात सामान्य करते.", "sambanova": "साइनअपवर $5 मोफत क्रेडिट्स (30 दिवसांची वैधता), क्रेडिट कार्डची आवश्यकता नाही", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "मॉडेल शोध AI_API_URL वरील /v2/lm/scenarios/foundation-models/models वापरतो. चॅट विनंत्या deploymentUrl/chat/completions वापरतात आणि त्यासाठी AI-Resource-Group आवश्यक आहे.", "sarvam": "Sarvam AI हे OpenAI-संगत आहे /v1. OmniRoute /v1/models चा शोध घेतो आणि चॅट ट्रॅफिक /v1/chat/completions कडे मार्गदर्शित करतो. मॉडेल्स भारतीय भाषांसाठी ट्यून केलेले आहेत.", "scaleway": "नवीन खात्यांसाठी 1M मोफत टोकन्स — EU/GDPR सुसंगत (पॅरिस), Qwen3 235B आणि Llama 70B", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index b506128049..186db22e3a 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -6239,6 +6239,7 @@ "requesty": "Cipta kunci API di https://app.requesty.ai, kemudian tampalkannya di sini sebagai token Bearer. Titik akhir serasi OpenAI di https://router.requesty.ai/v1, dengan katalog /v1/models langsung.", "runwayml": "Penjanaan video Runway adalah berasaskan tugas. OmniRoute menyerahkan kerja teks-ke-video atau imej-ke-video, meninjau /v1/tasks/[id], dan menormalkan output video yang telah selesai kembali ke dalam respons /v1/videos/generations seperti OpenAI.", "sambanova": "Kredit percuma $5 semasa pendaftaran (tempoh sah 30 hari), tiada kad kredit diperlukan", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Penemuan model menggunakan /v2/lm/scenarios/foundation-models/models pada AI_API_URL. Permintaan sembang menggunakan deploymentUrl/chat/completions dan memerlukan AI-Resource-Group.", "sarvam": "Sarvam AI adalah serasi dengan OpenAI pada /v1. OmniRoute menyiasat /v1/models dan mengarahkan trafik sembang ke /v1/chat/completions. Model disesuaikan untuk bahasa-bahasa India.", "scaleway": "1M token percuma untuk akaun baharu — mematuhi EU/GDPR (Paris), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index d92b373142..edf66b80a1 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -6239,6 +6239,7 @@ "requesty": "Maak een API-sleutel aan op https://app.requesty.ai en plak deze hier als een Bearer-token. OpenAI-compatibel eindpunt op https://router.requesty.ai/v1, met een live /v1/models-catalogus.", "runwayml": "Runway-videogeneratie is taakgebaseerd. OmniRoute dient text-to-video- of image-to-video-taken in, peilt /v1/tasks/[id] en normaliseert de voltooide video-uitvoer terug naar het OpenAI-achtige /v1/videos/generations-antwoord.", "sambanova": "$5 gratis tegoed bij aanmelding (30 dagen geldig), geen creditcard vereist", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Modeldetectie gebruikt /v2/lm/scenarios/foundation-models/models op AI_API_URL. Chatverzoeken gebruiken deploymentUrl/chat/completions en vereisen AI-Resource-Group.", "sarvam": "Sarvam AI is OpenAI-compatibel op /v1. OmniRoute onderzoekt /v1/models en leidt chatverkeer naar /v1/chat/completions. Modellen zijn afgestemd op Indic-talen.", "scaleway": "1M gratis tokens voor nieuwe accounts — EU/AVG-conform (Parijs), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 1e415be8aa..d4e45081b6 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -6239,6 +6239,7 @@ "requesty": "Opprett en API-nøkkel på https://app.requesty.ai, og lim den deretter inn her som et Bearer-token. OpenAI-kompatibelt endepunkt på https://router.requesty.ai/v1, med en live /v1/models-katalog.", "runwayml": "Runway-videogenerering er oppgavebasert. OmniRoute sender inn tekst-til-video- eller bilde-til-video-jobber, poller /v1/tasks/[id], og normaliserer de ferdige videoresultatene tilbake til den OpenAI-lignende /v1/videos/generations-responsen.", "sambanova": "$5 gratis kreditt ved registrering (30 dagers gyldighet), ikke krav om kredittkort", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Modellsøk bruker /v2/lm/scenarios/foundation-models/models på AI_API_URL. Chat-forespørsler bruker deploymentUrl/chat/completions og krever AI-Resource-Group.", "sarvam": "Sarvam AI er OpenAI-kompatibel på /v1. OmniRoute undersøker /v1/models og ruter chat-trafikk til /v1/chat/completions. Modeller er tilpasset for indiske språk.", "scaleway": "1M gratis tokens for nye kontoer — EU/GDPR-kompatibel (Paris), Qwen3 235B og Llama 70B", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index da06b21c62..6f9f973ec1 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -6239,6 +6239,7 @@ "requesty": "Gumawa ng API key sa https://app.requesty.ai, pagkatapos ay i-paste ito rito bilang isang Bearer token. OpenAI-compatible na endpoint sa https://router.requesty.ai/v1, na may live na catalog ng /v1/models.", "runwayml": "Ang pagbuo ng video sa Runway ay task-based. Nagpapasa ang OmniRoute ng mga text-to-video o image-to-video na job, nagpo-poll sa /v1/tasks/[id], at nino-normalize ang mga natapos na video output pabalik sa OpenAI-like na tugon ng /v1/videos/generations.", "sambanova": "$5 na libreng credits sa pag-signup (may bisa sa loob ng 30 araw), walang kinakailangang credit card", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Gumagamit ang pagtuklas ng modelo ng /v2/lm/scenarios/foundation-models/models sa AI_API_URL. Gumagamit ang mga kahilingan sa chat ng deploymentUrl/chat/completions at nangangailangan ng AI-Resource-Group.", "sarvam": "Ang Sarvam AI ay katugma ng OpenAI sa /v1. Ang OmniRoute ay nag-uusisa sa /v1/models at nagruruta ng chat traffic sa /v1/chat/completions. Ang mga modelo ay na-tune para sa mga wikang Indic.", "scaleway": "1M libreng token para sa mga bagong account — sumusunod sa EU/GDPR (Paris), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index ee567fe371..002c11a98d 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -6239,6 +6239,7 @@ "requesty": "Utwórz klucz API na https://app.requesty.ai, a następnie wklej go tutaj jako token Bearer. Punkt końcowy zgodny z OpenAI pod adresem https://router.requesty.ai/v1, z aktywnym katalogiem /v1/models.", "runwayml": "Generowanie wideo w Runway opiera się na zadaniach. OmniRoute przesyła zadania text-to-video lub image-to-video, odpytuje /v1/tasks/[id] i normalizuje gotowe wyniki wideo z powrotem do odpowiedzi w stylu OpenAI /v1/videos/generations.", "sambanova": "$5 darmowych kredytów przy rejestracji (ważność 30 dni), karta kredytowa nie jest wymagana", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Wykrywanie modeli używa /v2/lm/scenarios/foundation-models/models na AI_API_URL. Żądania czatu używają deploymentUrl/chat/completions i wymagają AI-Resource-Group.", "sarvam": "Sarvam AI jest zgodny z OpenAI na /v1. OmniRoute bada /v1/models i kieruje ruch czatu do /v1/chat/completions. Modele są dostosowane do języków indyjskich.", "scaleway": "1M darmowych tokenów dla nowych kont — zgodność z UE/RODO (Paryż), Qwen3 235B i Llama 70B", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 3fbe27e908..5717bff5cc 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -6243,6 +6243,7 @@ "requesty": "Crie uma chave de API em https://app.requesty.ai e cole-a aqui como um token Bearer. Endpoint compatível com OpenAI em https://router.requesty.ai/v1, com um catálogo /v1/models ao vivo.", "runwayml": "A geração de vídeo do Runway é baseada em tarefas. O OmniRoute envia jobs de texto-para-vídeo ou imagem-para-vídeo, consulta /v1/tasks/[id] periodicamente e normaliza as saídas de vídeo concluídas de volta para a resposta /v1/videos/generations no estilo OpenAI.", "sambanova": "$5 em créditos gratuitos no cadastro (validade de 30 dias), sem necessidade de cartão de crédito", + "seekai": "Crie uma chave de API em https://seekai.cc e cole aqui como Bearer token. URL base compatível com OpenAI: https://seekai.cc/v1.", "sap": "A descoberta de modelos usa /v2/lm/scenarios/foundation-models/models em AI_API_URL. As solicitações de chat usam deploymentUrl/chat/completions e requerem AI-Resource-Group.", "sarvam": "O Sarvam AI é compatível com OpenAI em /v1. O OmniRoute sonda /v1/models e roteia o tráfego de chat para /v1/chat/completions. Os modelos são ajustados para línguas indianas.", "scaleway": "1M tokens gratuitos para novas contas — compatível com UE/GDPR (Paris), Qwen3 235B e Llama 70B", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 4919461ca0..4722209d02 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -6239,6 +6239,7 @@ "requesty": "Crie uma chave de API em https://app.requesty.ai e, em seguida, cole-a aqui como um token Bearer. Endpoint compatível com a OpenAI em https://router.requesty.ai/v1, com um catálogo /v1/models em tempo real.", "runwayml": "A geração de vídeo da Runway é baseada em tarefas. O OmniRoute submete tarefas de texto para vídeo ou imagem para vídeo, consulta /v1/tasks/[id] e normaliza as saídas de vídeo concluídas de volta para a resposta /v1/videos/generations semelhante à da OpenAI.", "sambanova": "$5 em créditos gratuitos no registo (validade de 30 dias), sem necessidade de cartão de crédito", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "A descoberta de modelos utiliza /v2/lm/scenarios/foundation-models/models em AI_API_URL. Os pedidos de chat utilizam deploymentUrl/chat/completions e requerem AI-Resource-Group.", "sarvam": "Sarvam AI é compatível com OpenAI em /v1. OmniRoute investiga /v1/models e direciona o tráfego de chat para /v1/chat/completions. Os modelos são ajustados para línguas índicas.", "scaleway": "1M de tokens gratuitos para novas contas — em conformidade com a UE/RGPD (Paris), Qwen3 235B e Llama 70B", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 2d012143f9..a0d82ca4d5 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -6239,6 +6239,7 @@ "requesty": "Creează o cheie API la https://app.requesty.ai, apoi lipește-o aici ca token Bearer. Endpoint compatibil cu OpenAI la https://router.requesty.ai/v1, cu un catalog /v1/models live.", "runwayml": "Generarea video Runway este bazată pe sarcini. OmniRoute trimite lucrări text-to-video sau image-to-video, interoghează periodic /v1/tasks/[id] și normalizează ieșirile video finalizate înapoi în răspunsul de tip OpenAI /v1/videos/generations.", "sambanova": "$5 credite gratuite la înregistrare (valabilitate 30 de zile), nu este necesar un card de credit", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Descoperirea modelelor folosește /v2/lm/scenarios/foundation-models/models pe AI_API_URL. Solicitările de chat folosesc deploymentUrl/chat/completions și necesită AI-Resource-Group.", "sarvam": "Sarvam AI este compatibil cu OpenAI pe /v1. OmniRoute probează /v1/models și direcționează traficul de chat către /v1/chat/completions. Modelele sunt ajustate pentru limbile indic.", "scaleway": "1M tokenuri gratuite pentru conturi noi — conformitate UE/GDPR (Paris), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 9f6bd4762e..4a7646aaef 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -6239,6 +6239,7 @@ "requesty": "Создайте API-ключ на https://app.requesty.ai, затем вставьте его сюда в качестве Bearer-токена. Совместимая с OpenAI конечная точка находится по адресу https://router.requesty.ai/v1, с живым каталогом /v1/models.", "runwayml": "Генерация видео в Runway основана на задачах. OmniRoute отправляет задания text-to-video или image-to-video, опрашивает /v1/tasks/[id] и нормализует готовые видеовыходы обратно в ответ /v1/videos/generations, аналогичный OpenAI.", "sambanova": "$5 бесплатных кредитов при регистрации (срок действия 30 дней), кредитная карта не требуется", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Обнаружение моделей использует /v2/lm/scenarios/foundation-models/models на AI_API_URL. Запросы чата используют deploymentUrl/chat/completions и требуют AI-Resource-Group.", "sarvam": "Sarvam AI — (sarvam) — Индийские языковые AI модели", "scaleway": "1 млн бесплатных токенов для новых аккаунтов — соответствие EU/GDPR (Париж), Qwen3 235B и Llama 70B", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index b5fc50f625..66a01e79e2 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -6239,6 +6239,7 @@ "requesty": "Vytvorte API kľúč na https://app.requesty.ai, potom ho sem vložte ako Bearer token. Koncový bod kompatibilný s OpenAI na https://router.requesty.ai/v1, so živým katalógom /v1/models.", "runwayml": "Generovanie videa v Runway je založené na úlohách. OmniRoute odosiela úlohy text-to-video alebo image-to-video, dopytuje sa na /v1/tasks/[id] a normalizuje hotové video výstupy späť do odpovede /v1/videos/generations podobnej OpenAI.", "sambanova": "Bezplatný kredit 5 $ pri registrácii (platnosť 30 dní), nevyžaduje sa žiadna kreditná karta", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Vyhľadávanie modelov používa /v2/lm/scenarios/foundation-models/models na AI_API_URL. Požiadavky na chat používajú deploymentUrl/chat/completions a vyžadujú AI-Resource-Group.", "sarvam": "Sarvam AI je kompatibilný s OpenAI na /v1. OmniRoute skúma /v1/models a smeruje chatový prenos na /v1/chat/completions. Modely sú optimalizované pre indické jazyky.", "scaleway": "1M bezplatných tokenov pre nové účty — v súlade s EÚ/GDPR (Paríž), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index eb8a2bd8f3..d8ca20a04d 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -6239,6 +6239,7 @@ "requesty": "Skapa en API-nyckel på https://app.requesty.ai, klistra sedan in den här som en Bearer-token. OpenAI-kompatibel slutpunkt på https://router.requesty.ai/v1, med en live /v1/models-katalog.", "runwayml": "Runway-videogenerering är uppgiftsbaserad. OmniRoute skickar in text-till-video- eller bild-till-video-jobb, pollar /v1/tasks/[id] och normaliserar de färdiga videoutdata tillbaka till det OpenAI-liknande /v1/videos/generations-svaret.", "sambanova": "$5 i gratis kredit vid registrering (30 dagars giltighet), inget kreditkort krävs", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Modellidentifiering använder /v2/lm/scenarios/foundation-models/models på AI_API_URL. Chatförfrågningar använder deploymentUrl/chat/completions och kräver AI-Resource-Group.", "sarvam": "Sarvam AI är OpenAI-kompatibel på /v1. OmniRoute undersöker /v1/models och dirigerar chatttrafik till /v1/chat/completions. Modellerna är anpassade för indiska språk.", "scaleway": "1M gratis tokens för nya konton — EU/GDPR-kompatibel (Paris), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 31c55e5173..5507c02543 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -6239,6 +6239,7 @@ "requesty": "Unda ufunguo wa API kwenye https://app.requesty.ai, kisha ubandike hapa kama tokeni ya Bearer. Endpoint inayooana na OpenAI iko kwenye https://router.requesty.ai/v1, ikiwa na orodha ya moja kwa moja ya /v1/models.", "runwayml": "Uzalishaji wa video wa Runway unategemea kazi. OmniRoute huwasilisha kazi za maandishi-hadi-video au picha-hadi-video, huangalia mara kwa mara /v1/tasks/[id], na kurekebisha matokeo ya video yaliyokamilika kurudi kwenye jibu la /v1/videos/generations linalofanana na OpenAI.", "sambanova": "Salio la bure la $5 unapojisajili (uhalali wa siku 30), hakuna kadi ya mkopo inayohitajika", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Ugunduzi wa miundo hutumia /v2/lm/scenarios/foundation-models/models kwenye AI_API_URL. Maombi ya gumzo hutumia deploymentUrl/chat/completions na yanahitaji AI-Resource-Group.", "sarvam": "Sarvam AI inapatana na OpenAI kwenye /v1. OmniRoute inachunguza /v1/models na kuelekeza trafiki ya mazungumzo kwenye /v1/chat/completions. Mifano imeboreshwa kwa lugha za Kihindi.", "scaleway": "Tokeni 1M za bure kwa akaunti mpya — inatii EU/GDPR (Paris), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 49679193d9..a5e601ae93 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -6239,6 +6239,7 @@ "requesty": "https://app.requesty.ai இல் ஒரு API கீயை உருவாக்கி, பின்னர் அதை இங்கே Bearer டோக்கனாக ஒட்டவும். OpenAI-உடன் இணக்கமான எண்ட்பாயிண்ட் https://router.requesty.ai/v1 இல் நேரடி /v1/models பட்டியலுடன் உள்ளது.", "runwayml": "Runway வீடியோ உருவாக்கம் என்பது பணி அடிப்படையிலானது. OmniRoute ஆனது உரை-க்கு-வீடியோ அல்லது படம்-க்கு-வீடியோ பணிகளைச் சமர்ப்பித்து, /v1/tasks/[id] ஐத் தொடர்ந்து சரிபார்த்து, முடிக்கப்பட்ட வீடியோ வெளியீடுகளை மீண்டும் OpenAI போன்ற /v1/videos/generations பதிலுக்கு இயல்பாக்குகிறது.", "sambanova": "பதிவு செய்யும் போது $5 இலவச கிரெடிட்கள் (30 நாட்கள் செல்லுபடியாகும்), கிரெடிட் கார்டு தேவையில்லை", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "மாதிரி கண்டறிதல் ஆனது AI_API_URL இல் /v2/lm/scenarios/foundation-models/models ஐப் பயன்படுத்துகிறது. அரட்டை கோரிக்கைகள் deploymentUrl/chat/completions ஐப் பயன்படுத்துகின்றன மற்றும் AI-Resource-Group தேவைப்படுகிறது.", "sarvam": "Sarvam AI OpenAI-க்கு இணக்கமானது /v1 இல். OmniRoute /v1/models ஐ ஆராய்ந்து /v1/chat/completions க்கு உரையாடல் போக்குவரத்தை வழிமொழிகிறது. மாதிரிகள் இந்திய மொழிகளுக்காக அமைக்கப்பட்டுள்ளது.", "scaleway": "புதிய கணக்குகளுக்கு 1M இலவச டோக்கன்கள் — EU/GDPR இணக்கமானது (பாரிஸ்), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 1a5223b1c5..69a51f4d6a 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -6239,6 +6239,7 @@ "requesty": "https://app.requesty.ai వద్ద API కీని సృష్టించండి, ఆపై దానిని ఇక్కడ Bearer టోకెన్‌గా పేస్ట్ చేయండి. https://router.requesty.ai/v1 వద్ద లైవ్ /v1/models కేటలాగ్‌తో OpenAI-అనుకూల ఎండ్‌పాయింట్ ఉంది.", "runwayml": "Runway వీడియో జనరేషన్ టాస్క్-ఆధారితమైనది. OmniRoute అనేది టెక్స్ట్-టు-వీడియో లేదా ఇమేజ్-టు-వీడియో జాబ్‌లను సమర్పిస్తుంది, /v1/tasks/[id] ని పోల్ చేస్తుంది మరియు పూర్తయిన వీడియో అవుట్‌పుట్‌లను తిరిగి OpenAI-వంటి /v1/videos/generations ప్రతిస్పందనగా సాధారణీకరిస్తుంది.", "sambanova": "సైన్అప్ చేసినప్పుడు $5 ఉచిత క్రెడిట్‌లు (30 రోజుల చెల్లుబాటు), క్రెడిట్ కార్డ్ అవసరం లేదు", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "మోడల్ డిస్కవరీ AI_API_URL లో /v2/lm/scenarios/foundation-models/models ని ఉపయోగిస్తుంది. చాట్ అభ్యర్థనలు deploymentUrl/chat/completions ని ఉపయోగిస్తాయి మరియు AI-Resource-Group అవసరం.", "sarvam": "Sarvam AI OpenAI-తో అనుకూలంగా ఉంది /v1. OmniRoute /v1/modelsని ప్రోబ్ చేస్తుంది మరియు చాట్ ట్రాఫిక్‌ను /v1/chat/completionsకి రూట్ చేస్తుంది. మోడల్స్ ఇండిక్ భాషల కోసం ట్యూన్ చేయబడ్డాయి.", "scaleway": "కొత్త ఖాతాల కోసం 1M ఉచిత టోకెన్‌లు — EU/GDPR కంప్లైంట్ (పారిస్), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index a7093e6758..b43dda761c 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -6239,6 +6239,7 @@ "requesty": "สร้างคีย์ API ที่ https://app.requesty.ai จากนั้นวางที่นี่เป็นโทเค็น Bearer ปลายทางที่เข้ากันได้กับ OpenAI อยู่ที่ https://router.requesty.ai/v1 พร้อมแคตตาล็อก /v1/models แบบสด", "runwayml": "การสร้างวิดีโอของ Runway เป็นแบบอิงตามงาน OmniRoute จะส่งงาน text-to-video หรือ image-to-video ดึงข้อมูลสถานะ /v1/tasks/[id] เป็นระยะ และปรับเอาต์พุตวิดีโอที่เสร็จสมบูรณ์ให้อยู่ในรูปแบบการตอบกลับ /v1/videos/generations ที่คล้ายกับ OpenAI", "sambanova": "เครดิตฟรี $5 เมื่อลงทะเบียน (มีอายุ 30 วัน) ไม่ต้องใช้บัตรเครดิต", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "การค้นหาโมเดลใช้ /v2/lm/scenarios/foundation-models/models บน AI_API_URL คำขอแชทใช้ deploymentUrl/chat/completions และต้องระบุ AI-Resource-Group", "sarvam": "Sarvam AI เข้ากันได้กับ OpenAI ที่ /v1. OmniRoute ตรวจสอบ /v1/models และจัดเส้นทางการสนทนาไปยัง /v1/chat/completions. โมเดลได้รับการปรับแต่งสำหรับภาษาอินดิก.", "scaleway": "โทเค็นฟรี 1M สำหรับบัญชีใหม่ — สอดคล้องตาม EU/GDPR (ปารีส), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index c7ea532a2f..31e48fb100 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -6239,6 +6239,7 @@ "requesty": "https://app.requesty.ai adresinde bir API anahtarı oluşturun, ardından buraya Bearer token olarak yapıştırın. OpenAI uyumlu uç nokta, canlı bir /v1/models kataloğu ile birlikte https://router.requesty.ai/v1 adresindedir.", "runwayml": "Runway video üretimi görev tabanlıdır. OmniRoute, metinden videoya veya görselden videoya işleri gönderir, /v1/tasks/[id] uç noktasını sorgular ve tamamlanan video çıktılarını OpenAI benzeri /v1/videos/generations yanıtına normalleştirir.", "sambanova": "Kayıt olunduğunda $5 ücretsiz kredi (30 gün geçerli), kredi kartı gerekmez", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Model keşfi, AI_API_URL üzerindeki /v2/lm/scenarios/foundation-models/models yolunu kullanır. Sohbet istekleri deploymentUrl/chat/completions yolunu kullanır ve AI-Resource-Group gerektirir.", "sarvam": "Sarvam AI, OpenAI ile uyumludur ve /v1 üzerinde çalışır. OmniRoute, /v1/models'ı sorgular ve sohbet trafiğini /v1/chat/completions'a yönlendirir. Modeller, Hint dilleri için ayarlanmıştır.", "scaleway": "Yeni hesaplar için 1M ücretsiz token — AB/GDPR uyumlu (Paris), Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index f6a1e7f0b1..62d8db3226 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -6239,6 +6239,7 @@ "requesty": "Створіть API-ключ на https://app.requesty.ai, а потім вставте його сюди як Bearer-токен. OpenAI-сумісна кінцева точка на https://router.requesty.ai/v1, з живим каталогом /v1/models.", "runwayml": "Генерація відео в Runway базується на завданнях. OmniRoute надсилає завдання text-to-video або image-to-video, опитує /v1/tasks/[id] та нормалізує готові відеофайли назад у відповідь типу OpenAI /v1/videos/generations.", "sambanova": "$5 безкоштовних кредитів при реєстрації (дійсні 30 днів), кредитна картка не потрібна", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Виявлення моделей використовує /v2/lm/scenarios/foundation-models/models на AI_API_URL. Запити чату використовують deploymentUrl/chat/completions та потребують AI-Resource-Group.", "sarvam": "Sarvam AI сумісний з OpenAI на /v1. OmniRoute перевіряє /v1/models і маршрутизує чат-трафік на /v1/chat/completions. Моделі налаштовані для індійських мов.", "scaleway": "1 млн безкоштовних токенів для нових акаунтів — сумісно з EU/GDPR (Париж), Qwen3 235B та Llama 70B", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index bf80a21de8..1a4f1de826 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -6239,6 +6239,7 @@ "requesty": "https://app.requesty.ai پر ایک API key بنائیں، پھر اسے یہاں Bearer ٹوکن کے طور پر پیسٹ کریں۔ OpenAI سے ہم آہنگ اینڈ پوائنٹ https://router.requesty.ai/v1 پر ہے، جس میں ایک لائیو /v1/models کیٹلاگ موجود ہے۔", "runwayml": "Runway ویڈیو جنریشن ٹاسک پر مبنی ہے۔ OmniRoute، text-to-video یا image-to-video جابز جمع کراتا ہے، /v1/tasks/[id] کو پول کرتا ہے، اور مکمل شدہ ویڈیو آؤٹ پٹس کو واپس OpenAI جیسے /v1/videos/generations ریسپانس میں نارملائز کرتا ہے۔", "sambanova": "سائن اپ کرنے پر $5 مفت کریڈٹس (30 دن کی میعاد)، کسی کریڈٹ کارڈ کی ضرورت نہیں ہے", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "ماڈل کی دریافت AI_API_URL پر /v2/lm/scenarios/foundation-models/models کا استعمال کرتی ہے۔ چیٹ کی درخواستیں deploymentUrl/chat/completions کا استعمال کرتی ہیں اور ان کے لیے AI-Resource-Group درکار ہوتا ہے۔", "sarvam": "Sarvam AI OpenAI کے ساتھ ہم آہنگ ہے /v1 پر۔ OmniRoute /v1/models کی جانچ کرتا ہے اور چیٹ ٹریفک کو /v1/chat/completions پر بھیجتا ہے۔ ماڈلز کو انڈک زبانوں کے لیے ترتیب دیا گیا ہے۔", "scaleway": "نئے اکاؤنٹس کے لیے 1M مفت ٹوکنز — EU/GDPR کے مطابق (پیرس)، Qwen3 235B اور Llama 70B", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index f2e4a120bc..0a8428ecdf 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -6243,6 +6243,7 @@ "requesty": "Tạo khóa API tại https://app.requesty.ai, rồi dán dưới dạng token Bearer. Endpoint tương thích OpenAI tại https://router.requesty.ai/v1, kèm danh mục /v1/models trực tiếp.", "runwayml": "Tạo video Runway hoạt động theo tác vụ. OmniRoute gửi tác vụ chuyển văn bản hoặc hình ảnh thành video, thăm dò /v1/tasks/[id], rồi chuẩn hóa đầu ra hoàn tất về phản hồi /v1/videos/generations kiểu OpenAI.", "sambanova": "5 USD tín dụng miễn phí khi đăng ký (có hiệu lực 30 ngày), không cần thẻ tín dụng", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Khám phá mô hình dùng /v2/lm/scenarios/foundation-models/models trên AI_API_URL. Yêu cầu trò chuyện dùng deploymentUrl/chat/completions và yêu cầu AI-Resource-Group.", "sarvam": "Sarvam AI tương thích OpenAI trên /v1. OmniRoute thăm dò /v1/models và định tuyến trò chuyện tới /v1/chat/completions. Các mô hình được tinh chỉnh cho ngôn ngữ Ấn Độ.", "scaleway": "1 triệu token miễn phí cho tài khoản mới — tuân thủ EU/GDPR (Paris), Qwen3 235B và Llama 70B", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 2301729df6..facdccc8c2 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -6239,6 +6239,7 @@ "requesty": "在 https://app.requesty.ai 创建 API 密钥,然后将其作为 Bearer 令牌粘贴在此处。兼容 OpenAI 的端点位于 https://router.requesty.ai/v1,并提供实时的 /v1/models 目录。", "runwayml": "Runway 视频生成基于任务。OmniRoute 提交文生视频或图生视频作业,轮询 /v1/tasks/[id],并将完成的视频输出规范化为类似 OpenAI 的 /v1/videos/generations 响应。", "sambanova": "注册即送 $5 免费额度(30 天有效期),无需信用卡", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "模型发现使用 AI_API_URL 上的 /v2/lm/scenarios/foundation-models/models。Chat 请求使用 deploymentUrl/chat/completions 并需要 AI-Resource-Group。", "sarvam": "Sarvam AI 在 /v1 上兼容 OpenAI。OmniRoute 探测 /v1/models 并将聊天流量路由到 /v1/chat/completions。模型针对印度语言进行了优化。", "scaleway": "新账户可获 1M 免费 Token — 符合 EU/GDPR 规范(巴黎),Qwen3 235B & Llama 70B", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index f0dea4fe5c..3d10722987 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -6239,6 +6239,7 @@ "requesty": "在 https://app.requesty.ai 建立 API 金鑰,然後以 Bearer token 形式貼上。OpenAI 相容端點為 https://router.requesty.ai/v1,附即時 /v1/models 目錄。", "runwayml": "Runway 影片生成為任務導向。OmniRoute 提交文字轉影片或圖片轉影片作業,輪詢 /v1/tasks/[id],並將完成的影片輸出正規化為類似 OpenAI 的 /v1/videos/generations 回應。", "sambanova": "註冊即贈 $5 美元免費額度(30 天有效期),無需信用卡", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "模型探索使用 AI_API_URL 上的 /v2/lm/scenarios/foundation-models/models。聊天請求使用 deploymentUrl/chat/completions,並需要 AI-Resource-Group。", "sarvam": "使用API Key連接Sarvam AI。", "scaleway": "新帳戶贈送 100 萬免費 tokens — 符合歐盟/GDPR 規範(巴黎),Qwen3 235B 和 Llama 70B", diff --git a/src/shared/constants/config.ts b/src/shared/constants/config.ts index 0810042444..8e21b9c0dd 100644 --- a/src/shared/constants/config.ts +++ b/src/shared/constants/config.ts @@ -2,6 +2,7 @@ export { APP_CONFIG, THEME_CONFIG } from "./appConfig"; // Provider API endpoints (for display only) export const PROVIDER_ENDPOINTS = { + seekai: "https://seekai.cc/v1/chat/completions", agentrouter: "https://agentrouter.org/v1/chat/completions", openrouter: "https://openrouter.ai/api/v1/chat/completions", dgrid: "https://api.dgrid.ai/v1/chat/completions", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 7732808fbc..ef8d8ac94e 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -144,6 +144,7 @@ export const AGGREGATOR_PROVIDER_IDS = new Set([ "helixmind", "tabitoken", "logfare", + "seekai", ]); export const ENTERPRISE_CLOUD_PROVIDER_IDS = new Set([ diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index 1a37aab7f3..7f09204e3d 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -1435,4 +1435,24 @@ export const APIKEY_PROVIDERS_GATEWAYS = { apiHint: "Create an sk- key at https://tabitoken.com and use https://tabitoken.com. The Anthropic-compatible /v1/messages endpoint (default) takes x-api-key; /v1/chat/completions takes Bearer.", }, + // SeekAi (https://seekai.cc) — QuantumNous New-API aggregator. Live-verified + // 2026-09-02: GET /api/status → system_name=SeekAi, version=v1.0.0-rc.25, + // quota_display_type=USD. OpenAI-compatible /v1; models discovered live. + seekai: { + id: "seekai", + serviceKinds: ["llm"], + alias: "ska", + name: "SeekAi", + icon: "hub", + color: "#0D9488", + textIcon: "SK", + passthroughModels: true, + website: "https://seekai.cc", + hasFree: true, + freeNote: "Signup credit toward available models; amount and eligibility are set by SeekAi, not OmniRoute.", + authHint: + "Create an API key at https://seekai.cc, then paste it here as a Bearer token.", + apiHint: + "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", + }, }; diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 64fd60cfa2..63db4728b1 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -5288,6 +5288,29 @@ "stream": "https://api.sea-lion.ai/v1/chat/completions" } }, + "seekai": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "https://seekai.cc/v1/chat/completions", + "stream": "https://seekai.cc/v1/chat/completions" + } + }, "sensenova": { "format": "openai", "headers": { diff --git a/tests/unit/provider-node-reserved-prefix.test.ts b/tests/unit/provider-node-reserved-prefix.test.ts index 26c2250753..a20918edab 100644 --- a/tests/unit/provider-node-reserved-prefix.test.ts +++ b/tests/unit/provider-node-reserved-prefix.test.ts @@ -179,7 +179,8 @@ test("shared set size includes live REGISTRY and retired Designer + Felo + Qwen // alias "gembiz" to the REGISTRY walk (406 → 408). // 2026-09-02: a keyless provider was removed at its operator's request, taking its id and // alias out of the REGISTRY walk (408 → 406). - assert.equal(RESERVED_PREFIX_COUNT, 406); + // #11786: SeekAi adds id "seekai" + alias "ska" (406 → 408). + assert.equal(RESERVED_PREFIX_COUNT, 408); }); test("isReservedProviderPrefix rejects non-string input", () => { diff --git a/tests/unit/providers-constants-split.test.ts b/tests/unit/providers-constants-split.test.ts index d6196816ca..6d4ae62c89 100644 --- a/tests/unit/providers-constants-split.test.ts +++ b/tests/unit/providers-constants-split.test.ts @@ -32,7 +32,8 @@ // volcengine-coding-plan (regional family) — both land at 233. // release/v3.8.51 adds Opper (gateways, #11629) and 1min.ai (gateways, #11631) — lands at 235; // Perplexity Agent API (#12103) makes it 236; -// UC Direct (#11513, uncensored.com metered Developer API) adds one frontier-labs entry — 237. +// UC Direct (#11513, uncensored.com metered Developer API) adds one frontier-labs entry — 237; +// SeekAi (#11786, QuantumNous New-API gateway) adds one gateways entry — 238. import { test } from "node:test"; import assert from "node:assert/strict"; @@ -61,12 +62,12 @@ test("barrel still exports every catalog + key helpers", () => { } }); -test("APIKEY_PROVIDERS merges the 6 family files into 237 entries (no loss / no dup)", async () => { +test("APIKEY_PROVIDERS merges the 6 family files into 238 entries (no loss / no dup)", async () => { const keys = Object.keys((P as Record).APIKEY_PROVIDERS); - assert.equal(keys.length, 237); - assert.equal(new Set(keys).size, 237, "duplicate keys after spread-merge"); + assert.equal(keys.length, 238); + assert.equal(new Set(keys).size, 238, "duplicate keys after spread-merge"); // the merged object's entry-count equals the sum of the 6 semantic family files; families are a - // strict partition (every provider in exactly one), so the sum must be exactly 237. + // strict partition (every provider in exactly one), so the sum must be exactly 238. const families: [string, string][] = [ ["gateways", "APIKEY_PROVIDERS_GATEWAYS"], ["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"], @@ -86,7 +87,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 237 entries (no loss / no seen.add(k); } } - assert.equal(famTotal, 237, "families must partition all 237 providers"); + assert.equal(famTotal, 238, "families must partition all 238 providers"); }); test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => { diff --git a/tests/unit/seekai-provider.test.ts b/tests/unit/seekai-provider.test.ts new file mode 100644 index 0000000000..13f3ca512a --- /dev/null +++ b/tests/unit/seekai-provider.test.ts @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { APIKEY_PROVIDERS, AGGREGATOR_PROVIDER_IDS } = await import( + "../../src/shared/constants/providers.ts" +); +const { PROVIDER_ENDPOINTS } = await import("../../src/shared/constants/config.ts"); +const { REGISTRY: providerRegistry } = await import("../../open-sse/config/providerRegistry.ts"); +const { isValidModel } = await import("../../src/shared/constants/models.ts"); +const { DefaultExecutor, getExecutor } = await import("../../open-sse/executors/index.ts"); + +const SEEKAI_CHAT_URL = "https://seekai.cc/v1/chat/completions"; +const SEEKAI_MODELS_URL = "https://seekai.cc/v1/models"; + +test("#11786 seekai is registered as an API-key gateway provider", () => { + const entry = APIKEY_PROVIDERS.seekai; + assert.ok(entry, "APIKEY_PROVIDERS.seekai must be defined"); + assert.equal(entry.id, "seekai"); + assert.equal(entry.alias, "ska"); + assert.equal(entry.name, "SeekAi"); + assert.equal(entry.website, "https://seekai.cc"); + assert.equal(entry.passthroughModels, true); + assert.equal(entry.hasFree, true); + assert.equal(typeof entry.authHint, "string"); + assert.ok((entry.authHint as string).length > 0); + assert.equal(typeof entry.apiHint, "string"); + assert.ok((entry.apiHint as string).length > 0); +}); + +test("#11786 seekai website and hints carry no referral/aff query", () => { + const entry = APIKEY_PROVIDERS.seekai; + const haystack = [entry.website, entry.apiHint, entry.authHint, entry.freeNote] + .filter((value): value is string => typeof value === "string") + .join("\n"); + assert.equal(/[?&]aff=/.test(haystack), false); + assert.equal(haystack.includes("qR5U"), false); +}); + +test("#11786 seekai registry entry uses OpenAI format with bearer API-key auth", () => { + const entry = providerRegistry.seekai; + assert.ok(entry, "providerRegistry.seekai must be defined"); + assert.equal(entry.id, "seekai"); + assert.equal(entry.alias, "ska"); + assert.equal(entry.format, "openai"); + assert.equal(entry.executor, "default"); + assert.equal(entry.authType, "apikey"); + assert.equal(entry.authHeader, "bearer"); + assert.equal(entry.baseUrl, SEEKAI_CHAT_URL); + assert.equal(entry.modelsUrl, SEEKAI_MODELS_URL); + assert.equal(entry.passthroughModels, true); +}); + +test("#11786 seekai discovers models live via passthrough (no static seed list)", () => { + assert.deepEqual(providerRegistry.seekai.models, []); + assert.equal(providerRegistry.seekai.passthroughModels, true); +}); + +test("#11786 seekai accepts any model id via passthrough", () => { + assert.equal(isValidModel("seekai", "claude-sonnet-5"), true); + assert.equal(isValidModel("ska", "gpt-5.6"), true); +}); + +test("#11786 seekai is on the aggregator list and display endpoint", async () => { + assert.equal(AGGREGATOR_PROVIDER_IDS.has("seekai"), true); + assert.equal(PROVIDER_ENDPOINTS.seekai, SEEKAI_CHAT_URL); + assert.ok((await getExecutor("seekai")) instanceof DefaultExecutor); +}); From 831ea040c3a45cc24ecffeba2bd3834f360b1f69 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Thu, 3 Sep 2026 11:47:50 -0400 Subject: [PATCH 33/47] feat(quota): Moonshot Open Platform balance and TPD lock for custom nodes (#12590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca. O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. --- AGENTS.md | 2 +- README.md | 2 +- config/quality/file-size-baseline.json | 11 +- docs/i18n/ar/llm.txt | 8 +- docs/i18n/az/llm.txt | 8 +- docs/i18n/bg/llm.txt | 8 +- docs/i18n/bn/llm.txt | 8 +- docs/i18n/cs/llm.txt | 8 +- docs/i18n/da/llm.txt | 8 +- docs/i18n/de/llm.txt | 8 +- docs/i18n/es/llm.txt | 8 +- docs/i18n/fa/llm.txt | 8 +- docs/i18n/fi/llm.txt | 8 +- docs/i18n/fr/llm.txt | 8 +- docs/i18n/gu/llm.txt | 8 +- docs/i18n/he/llm.txt | 8 +- docs/i18n/hi/llm.txt | 8 +- docs/i18n/hu/llm.txt | 8 +- docs/i18n/id/llm.txt | 8 +- docs/i18n/it/llm.txt | 8 +- docs/i18n/ja/llm.txt | 8 +- docs/i18n/ko/llm.txt | 8 +- docs/i18n/mr/llm.txt | 8 +- docs/i18n/ms/llm.txt | 8 +- docs/i18n/nl/llm.txt | 8 +- docs/i18n/no/llm.txt | 8 +- docs/i18n/phi/llm.txt | 8 +- docs/i18n/pl/llm.txt | 8 +- docs/i18n/pt-BR/llm.txt | 8 +- docs/i18n/pt/llm.txt | 8 +- docs/i18n/ro/llm.txt | 8 +- docs/i18n/ru/llm.txt | 8 +- docs/i18n/sk/llm.txt | 8 +- docs/i18n/sv/llm.txt | 8 +- docs/i18n/sw/llm.txt | 8 +- docs/i18n/ta/llm.txt | 8 +- docs/i18n/te/llm.txt | 8 +- docs/i18n/th/llm.txt | 8 +- docs/i18n/tr/llm.txt | 8 +- docs/i18n/uk-UA/llm.txt | 8 +- docs/i18n/ur/llm.txt | 8 +- docs/i18n/vi/llm.txt | 8 +- docs/i18n/zh-CN/llm.txt | 8 +- docs/i18n/zh-TW/llm.txt | 8 +- llm.txt | 8 +- open-sse/services/accountFallback.ts | 67 +++-- open-sse/services/dailyQuotaReset.ts | 145 +++++++++++ open-sse/services/moonshotQuotaFetcher.ts | 229 ++++++++++++++++++ open-sse/services/usage.ts | 9 + open-sse/services/usage/fetcherProviders.ts | 2 + .../services/usage/moonshotOpenPlatform.ts | 76 ++++++ open-sse/services/usage/supportedProviders.ts | 2 + .../modals/EditCompatibleNodeModal.tsx | 29 +++ .../usage/components/ProviderLimits/index.tsx | 5 +- .../(dashboard)/home/ProviderQuotaWidget.tsx | 5 +- src/app/api/provider-nodes/[id]/route.ts | 20 +- src/app/api/provider-nodes/route.ts | 35 +++ src/i18n/messages/ar.json | 4 + src/i18n/messages/az.json | 4 + src/i18n/messages/bg.json | 4 + src/i18n/messages/bn.json | 4 + src/i18n/messages/cs.json | 4 + src/i18n/messages/da.json | 4 + src/i18n/messages/de.json | 4 + src/i18n/messages/en.json | 4 + src/i18n/messages/es.json | 4 + src/i18n/messages/fa.json | 4 + src/i18n/messages/fi.json | 4 + src/i18n/messages/fr.json | 4 + src/i18n/messages/gu.json | 4 + src/i18n/messages/he.json | 4 + src/i18n/messages/hi.json | 4 + src/i18n/messages/hu.json | 4 + src/i18n/messages/id.json | 4 + src/i18n/messages/it.json | 4 + src/i18n/messages/ja.json | 4 + src/i18n/messages/ko.json | 4 + src/i18n/messages/mr.json | 4 + src/i18n/messages/ms.json | 4 + src/i18n/messages/nl.json | 4 + src/i18n/messages/no.json | 4 + src/i18n/messages/phi.json | 4 + src/i18n/messages/pl.json | 4 + src/i18n/messages/pt-BR.json | 4 + src/i18n/messages/pt.json | 4 + src/i18n/messages/ro.json | 4 + src/i18n/messages/ru.json | 4 + src/i18n/messages/sk.json | 4 + src/i18n/messages/sv.json | 4 + src/i18n/messages/sw.json | 4 + src/i18n/messages/ta.json | 4 + src/i18n/messages/te.json | 4 + src/i18n/messages/th.json | 4 + src/i18n/messages/tr.json | 4 + src/i18n/messages/uk-UA.json | 4 + src/i18n/messages/ur.json | 4 + src/i18n/messages/vi.json | 4 + src/i18n/messages/zh-CN.json | 4 + src/i18n/messages/zh-TW.json | 4 + src/instrumentation-node.ts | 16 ++ src/lib/db/migrationRunner.ts | 5 + .../172_provider_node_daily_quota_reset.sql | 6 + src/lib/db/providers/nodes.ts | 59 ++--- src/lib/usage/apiKeySelfService.ts | 20 +- src/lib/usage/providerLimits.ts | 21 +- src/shared/utils/classify429.ts | 11 + src/shared/utils/providerQuotaVisibility.ts | 14 +- src/shared/validation/schemas/provider.ts | 28 ++- src/sse/handlers/chat.ts | 16 ++ src/sse/services/auth.ts | 25 +- stryker.conf.json | 1 + tests/unit/account-fallback-service.test.ts | 48 ++++ tests/unit/api-key-self-service.test.ts | 36 +++ tests/unit/classify429.test.ts | 15 ++ tests/unit/daily-quota-reset.test.ts | 54 +++++ tests/unit/moonshot-open-platform.test.ts | 72 ++++++ tests/unit/moonshot-quota-fetcher.test.ts | 158 ++++++++++++ tests/unit/moonshot-quota-writeback.test.ts | 50 ++++ .../provider-node-daily-reset-schema.test.ts | 54 +++++ tests/unit/provider-quota-visibility.test.ts | 12 + tests/unit/qoder-usage-quota.test.ts | 9 + 121 files changed, 1626 insertions(+), 247 deletions(-) create mode 100644 open-sse/services/dailyQuotaReset.ts create mode 100644 open-sse/services/moonshotQuotaFetcher.ts create mode 100644 open-sse/services/usage/moonshotOpenPlatform.ts create mode 100644 src/lib/db/migrations/172_provider_node_daily_quota_reset.sql create mode 100644 tests/unit/daily-quota-reset.test.ts create mode 100644 tests/unit/moonshot-open-platform.test.ts create mode 100644 tests/unit/moonshot-quota-fetcher.test.ts create mode 100644 tests/unit/moonshot-quota-writeback.test.ts create mode 100644 tests/unit/provider-node-daily-reset-schema.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1e90b6ceed..6e7ad18f2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below. | Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | | Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | | Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | -| Database | `src/lib/db/` | SQLite domain modules (168 migrations) | +| Database | `src/lib/db/` | SQLite domain modules (169 migrations) | | Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | | MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes | | A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | diff --git a/README.md b/README.md index 119a590cb3..9814b0e62d 100644 --- a/README.md +++ b/README.md @@ -1244,7 +1244,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
RuntimeNode.js 22.x / 24.x LTS — >=22.22.2 <23 || >=24.0.0 <27 LanguageTypeScript 6.0 — 100% TypeScript across src/ and open-sse/ (zero any in core since v2.0) FrameworkNext.js 16 + React 19 + Tailwind CSS 4 - Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 168 migrations + Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 169 migrations MemorySQLite FTS5 full-text + int8-quantized vector embeddings, typed decay SchemasZod 4 — MCP tool I/O validation + API contracts ProtocolsMCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index e5d0287a65..cfa43566b4 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_09_03_moonshot_native_quota": "PR feat/moonshot-native-quota own growth on release/v3.8.51: src/lib/db/migrationRunner.ts 1201->1206 (+5, case 172 retroactive guard for daily_quota_reset_* columns); src/sse/handlers/chat.ts 2434->2450 (+16, registerMoonshotQuotaFetcher + startup node scan at the existing quota-fetcher registration chokepoint); src/sse/services/auth.ts 3427->3450 (+23, resolveDailyResetForProvider + dailyReset arg on checkFallbackError); open-sse/services/accountFallback.ts 2422->2461 (+39, compatible-node credits_exhausted carve-out + TPD node-clock lock); tests/unit/account-fallback-service.test.ts 2008->2056 (+48, TPD/empty-wallet cases). Wiring at existing chokepoints; Moonshot host predicates, daily reset clock, and the balance fetcher live in new leaves under cap. Covered by tests/unit/moonshot-*.test.ts + account-fallback-service.test.ts (135/135 focused).", "_rebaseline_2026_09_02_11786_seekai_provider": "PR #11786 (feat/11786-seekai-provider, closes #11786) own growth: src/shared/constants/providers/apikey/gateways.ts 1438->1458 (check-file-size split-newline=1459; the seekai APIKEY_PROVIDERS_GATEWAYS catalog entry plus authHint, additive data at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines: #10987 logfare, #10531 freebuff). Covered by tests/unit/seekai-provider.test.ts.", "_rebaseline_2026_09_02_12325_generic_429_invalidate": "PR #12325 own growth: open-sse/handlers/chatCore.ts 5946->5955 (+9 = the non-Codex 429 else-if that drops the generic quota wrapper and stamps force-refresh, plus a source-regex breadcrumb). Irreducible call-site wiring next to the existing Codex 429 invalidateCodexQuotaCache branch; not extractable without splitting handleChatCore mid-response. Covered by tests/unit/generic-quota-fetcher.test.ts (31/31) and tests/unit/antigravity-429-quota-cooldown.test.ts.", "_rebaseline_2026_09_02_12429_wreq_migration_suite": "PR #12429 (wreq-js web-cookie transport): new test file tests/unit/tls-client-wreq-migration.test.ts at 1374 lines, above the 1200 new-file testCap. Frozen rather than split: it is the single cohesive regression suite for the transport migration (31 cases covering streaming, fragmented EOF sentinels, proxy isolation, first-byte and hard deadlines, binary responses and cancellation), and the cases share the native-transport harness the file sets up once. Splitting it during a merge would duplicate that harness across files for no coverage gain. Entered at the exact LOC, so it can only ratchet down from here.", @@ -206,7 +207,7 @@ "_rebaseline_basered_codebuddy_cn": "Base-red fix (#4664 CodeBuddy CN): oauth-providers-config.test.ts 867->870 (+3) to align the EXPECTED provider list/config with the codebuddy-cn provider that #4664 added to the registry without updating this test (it asserts 'exactly once').", "_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", "tests/integration/chat-pipeline.test.ts": 1644, - "tests/unit/account-fallback-service.test.ts": 2008, + "tests/unit/account-fallback-service.test.ts": 2056, "tests/unit/batch_api.test.ts": 1345, "tests/unit/cc-compatible-provider.test.ts": 1225, "tests/unit/chatcore-translation-paths.test.ts": 3447, @@ -419,7 +420,7 @@ "open-sse/handlers/search.ts": 1789, "open-sse/mcp-server/schemas/tools.ts": 1621, "open-sse/mcp-server/server.ts": 1572, - "open-sse/services/accountFallback.ts": 2422, + "open-sse/services/accountFallback.ts": 2461, "open-sse/services/adobeFireflyBrowserLogin.ts": 1401, "open-sse/services/combo.ts": 4023, "open-sse/translator/response/openai-responses.ts": 1466, @@ -447,14 +448,14 @@ "src/app/docs/lib/openapi.generated.ts": 1347, "src/lib/db/apiKeys.ts": 1610, "src/lib/db/core.ts": 1745, - "src/lib/db/migrationRunner.ts": 1201, + "src/lib/db/migrationRunner.ts": 1206, "src/lib/tailscaleTunnel.ts": 1208, "src/lib/tokenHealthCheck.ts": 1218, "src/shared/components/RequestLoggerV2.tsx": 1718, "src/shared/constants/providers/apikey/gateways.ts": 1459, "src/shared/services/cliRuntime.ts": 1296, - "src/sse/handlers/chat.ts": 2424, - "src/sse/services/auth.ts": 3427, + "src/sse/handlers/chat.ts": 2450, + "src/sse/services/auth.ts": 3450, "tests/unit/account-fallback-service.test.ts": 2453, "tests/unit/provider-validation-specialty.test.ts": 4656 }, diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt index 603773977a..9f405eda91 100644 --- a/docs/i18n/ar/llm.txt +++ b/docs/i18n/ar/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt index 3af7361352..74a600ba34 100644 --- a/docs/i18n/az/llm.txt +++ b/docs/i18n/az/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt index c510c85d7e..0db6d8a4ce 100644 --- a/docs/i18n/bg/llm.txt +++ b/docs/i18n/bg/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt index 54b9ca1d9a..a7417f642e 100644 --- a/docs/i18n/bn/llm.txt +++ b/docs/i18n/bn/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt index 4dd9012d51..c8b5c31922 100644 --- a/docs/i18n/cs/llm.txt +++ b/docs/i18n/cs/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt index d29af5f81c..3d9663c28f 100644 --- a/docs/i18n/da/llm.txt +++ b/docs/i18n/da/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt index 0002d85510..0c8572e4f4 100644 --- a/docs/i18n/de/llm.txt +++ b/docs/i18n/de/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt index c423fdd245..75a6488e37 100644 --- a/docs/i18n/es/llm.txt +++ b/docs/i18n/es/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt index d8b83418ea..cdcccf2fc8 100644 --- a/docs/i18n/fa/llm.txt +++ b/docs/i18n/fa/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt index b91777ac80..66d62309f4 100644 --- a/docs/i18n/fi/llm.txt +++ b/docs/i18n/fi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt index 7e396ec1e9..dabd29e87f 100644 --- a/docs/i18n/fr/llm.txt +++ b/docs/i18n/fr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt index 75561762bf..21dad6d8f9 100644 --- a/docs/i18n/gu/llm.txt +++ b/docs/i18n/gu/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt index e8dab2b860..4174e2aa0f 100644 --- a/docs/i18n/he/llm.txt +++ b/docs/i18n/he/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt index 3df2c700b6..b08baf5189 100644 --- a/docs/i18n/hi/llm.txt +++ b/docs/i18n/hi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt index bd0667630c..13109da6fc 100644 --- a/docs/i18n/hu/llm.txt +++ b/docs/i18n/hu/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt index 0cce4bc272..77df7b4c16 100644 --- a/docs/i18n/id/llm.txt +++ b/docs/i18n/id/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt index 0763f0b728..b479c853cd 100644 --- a/docs/i18n/it/llm.txt +++ b/docs/i18n/it/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt index 3dbb124940..a1528c646b 100644 --- a/docs/i18n/ja/llm.txt +++ b/docs/i18n/ja/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt index 0d44a5b222..4d8508fd12 100644 --- a/docs/i18n/ko/llm.txt +++ b/docs/i18n/ko/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt index 9410d228f5..8ba71d98f5 100644 --- a/docs/i18n/mr/llm.txt +++ b/docs/i18n/mr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt index e610a66e29..3903f94cf1 100644 --- a/docs/i18n/ms/llm.txt +++ b/docs/i18n/ms/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt index a4364f5ac2..8db8eb985b 100644 --- a/docs/i18n/nl/llm.txt +++ b/docs/i18n/nl/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt index 7daa5caa07..e635301c86 100644 --- a/docs/i18n/no/llm.txt +++ b/docs/i18n/no/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt index 208e4a1398..b760a56857 100644 --- a/docs/i18n/phi/llm.txt +++ b/docs/i18n/phi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt index 79f3f32583..e683cdb76c 100644 --- a/docs/i18n/pl/llm.txt +++ b/docs/i18n/pl/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt index e795080292..49a62961e7 100644 --- a/docs/i18n/pt-BR/llm.txt +++ b/docs/i18n/pt-BR/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt index ffa62b14e6..9362022f86 100644 --- a/docs/i18n/pt/llm.txt +++ b/docs/i18n/pt/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt index 7a6f5067a9..fd21c2042b 100644 --- a/docs/i18n/ro/llm.txt +++ b/docs/i18n/ro/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt index ed8415886d..d68314887e 100644 --- a/docs/i18n/ru/llm.txt +++ b/docs/i18n/ru/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt index 11385eade3..666d1a570e 100644 --- a/docs/i18n/sk/llm.txt +++ b/docs/i18n/sk/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt index 5a0f5b5c80..74238247ae 100644 --- a/docs/i18n/sv/llm.txt +++ b/docs/i18n/sv/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt index ef792bc7e7..5f129a8263 100644 --- a/docs/i18n/sw/llm.txt +++ b/docs/i18n/sw/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt index 2c0694b490..d42fa346a0 100644 --- a/docs/i18n/ta/llm.txt +++ b/docs/i18n/ta/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt index 29ff10874a..6ca040018f 100644 --- a/docs/i18n/te/llm.txt +++ b/docs/i18n/te/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt index 4096423b6d..9884ab3b21 100644 --- a/docs/i18n/th/llm.txt +++ b/docs/i18n/th/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt index b83be6d908..77b9eadb1b 100644 --- a/docs/i18n/tr/llm.txt +++ b/docs/i18n/tr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt index f3cf580495..3a7bc01f36 100644 --- a/docs/i18n/uk-UA/llm.txt +++ b/docs/i18n/uk-UA/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt index 9efbef2f56..1cdd0e6bb5 100644 --- a/docs/i18n/ur/llm.txt +++ b/docs/i18n/ur/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt index eef12beee4..3e28435b0a 100644 --- a/docs/i18n/vi/llm.txt +++ b/docs/i18n/vi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt index f1c4a2b574..d11163a83a 100644 --- a/docs/i18n/zh-CN/llm.txt +++ b/docs/i18n/zh-CN/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt index b7b1342bf5..56256a53da 100644 --- a/docs/i18n/zh-TW/llm.txt +++ b/docs/i18n/zh-TW/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/llm.txt b/llm.txt index 13d3c28e78..3748b08432 100644 --- a/llm.txt +++ b/llm.txt @@ -14,7 +14,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -124,7 +124,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -389,7 +389,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -433,7 +433,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 168 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index f5bd87c1ed..c6465321f9 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -64,6 +64,8 @@ import { parseDelayString, MAX_SHORT_RETRY_HINT_MS, } from "./retryAfterJson.ts"; +import { isMoonshotAccountBalanceExhausted } from "./usage/moonshotOpenPlatform.ts"; +import { isTpdRateLimit, resolveTpdCooldownMs } from "./dailyQuotaReset.ts"; // Pre-compiled regex constants for hot-path retry parsing (avoid per-call compilation) const RETRY_AFTER_RE = /retry\s+after\s+(\d+)\s*s/i; @@ -1605,7 +1607,8 @@ export function isDailyQuotaExhausted(errorText: string): boolean { return ( lower.includes("today's quota") || lower.includes("daily quota") || - lower.includes("try again tomorrow") + lower.includes("try again tomorrow") || + lower.includes("tpd rate limit") ); } @@ -1651,7 +1654,12 @@ export function checkFallbackError( headers: Headers | Record | null = null, profileOverride: ProviderProfile | null = null, structuredError?: { code?: string | null; type?: string | null } | null, - rotation?: { account?: unknown } | null + rotation?: { account?: unknown } | null, + dailyReset?: { + timezone?: unknown; + hour?: unknown; + nowMs?: number; + } | null, ): { shouldFallback: boolean; cooldownMs: number; @@ -1934,8 +1942,13 @@ export function checkFallbackError( } } - // T10 (sub2api #1169) + #8247: credits/quota exhausted; *-compatible-* nicknames stay model-scoped. - if (shouldUseQuotaSignal && isCreditsExhausted(errorStr) && !isCompatibleProvider(provider)) { + // T10 (sub2api #1169) + #8247: credits/quota exhausted; *-compatible-* nicknames stay model-scoped + // unless the body is an account-level Open Platform empty wallet. + if ( + shouldUseQuotaSignal && + isCreditsExhausted(errorStr) && + (!isCompatibleProvider(provider) || isMoonshotAccountBalanceExhausted(errorStr)) + ) { return { shouldFallback: true, cooldownMs: COOLDOWN_MS.paymentRequired ?? 3600 * 1000, // 1h cooldown @@ -1944,17 +1957,43 @@ export function checkFallbackError( }; } - // Daily quota exhausted — lock model until tomorrow + // Daily quota exhausted. TPD uses the node clock / header; other daily + // quota text still uses getMsUntilTomorrow. TPD without either is not a + // host-midnight lock — fall through to short 429. if (shouldUseQuotaSignal && isDailyQuotaExhausted(errorStr)) { - const msUntilTomorrow = getMsUntilTomorrow(); - // Cap at 24 hours to handle timezone edge cases - const cooldownMs = Math.min(msUntilTomorrow, 24 * 60 * 60 * 1000); - return { - shouldFallback: true, - cooldownMs, - reason: RateLimitReason.QUOTA_EXHAUSTED, - dailyQuotaExhausted: true, - }; + if (isTpdRateLimit(errorStr)) { + const headerResetAtMs = parseResetFromHeaders(headers); + const tpdMs = resolveTpdCooldownMs(errorStr, { + timezone: dailyReset?.timezone, + hour: dailyReset?.hour, + nowMs: dailyReset?.nowMs, + headerResetAtMs, + }); + if (tpdMs == null) { + // no clock, no header — short 429, do not guess midnight + console.warn( + "[accountFallback] TPD 429 without node daily-reset clock or Reset header; using short cooldown", + { provider }, + ); + } else { + return { + shouldFallback: true, + cooldownMs: tpdMs, + reason: RateLimitReason.QUOTA_EXHAUSTED, + dailyQuotaExhausted: true, + }; + } + } else { + const msUntilTomorrow = getMsUntilTomorrow(); + // Cap at 24 hours to handle timezone edge cases + const cooldownMs = Math.min(msUntilTomorrow, 24 * 60 * 60 * 1000); + return { + shouldFallback: true, + cooldownMs, + reason: RateLimitReason.QUOTA_EXHAUSTED, + dailyQuotaExhausted: true, + }; + } } // Issue #2321 (5h subscription quota) + Issue #3709 (ollama-cloud weekly diff --git a/open-sse/services/dailyQuotaReset.ts b/open-sse/services/dailyQuotaReset.ts new file mode 100644 index 0000000000..a5108d132f --- /dev/null +++ b/open-sse/services/dailyQuotaReset.ts @@ -0,0 +1,145 @@ +/** + * Node-level daily quota reset clock. + * + * TPD cooldown endpoint: operator-configured IANA timezone + local hour. + * No default timezone. Do not call getMsUntilTomorrow() from here. + */ + +export function isValidIanaTimeZone(tz: string): boolean { + if (typeof tz !== "string" || tz.trim() === "") return false; + try { + new Intl.DateTimeFormat("en-US", { timeZone: tz.trim() }).format(); + return true; + } catch { + return false; + } +} + +export function isValidResetHour(hour: unknown): hour is number { + return typeof hour === "number" && Number.isInteger(hour) && hour >= 0 && hour <= 23; +} + +export function nodeDailyResetConfigured(timezone: unknown, hour: unknown): boolean { + return typeof timezone === "string" && isValidIanaTimeZone(timezone) && isValidResetHour(hour); +} + +type ZonedParts = { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +}; + +function zonedParts(ms: number, timeZone: string): ZonedParts { + const fmt = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const bag: Record = {}; + for (const part of fmt.formatToParts(new Date(ms))) { + if (part.type !== "literal") bag[part.type] = part.value; + } + return { + year: Number(bag.year), + month: Number(bag.month), + day: Number(bag.day), + hour: Number(bag.hour), + minute: Number(bag.minute), + second: Number(bag.second), + }; +} + +function addCalendarDay(year: number, month: number, day: number): { + year: number; + month: number; + day: number; +} { + const utc = Date.UTC(year, month - 1, day + 1); + const dt = new Date(utc); + return { year: dt.getUTCFullYear(), month: dt.getUTCMonth() + 1, day: dt.getUTCDate() }; +} + +/** Convert wall-clock time in `timeZone` to epoch ms. */ +function zonedLocalToUtc( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, + timeZone: string, +): number { + const wanted = Date.UTC(year, month - 1, day, hour, minute, second); + let guess = wanted; + for (let i = 0; i < 4; i++) { + const p = zonedParts(guess, timeZone); + const asIfUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); + const delta = asIfUtc - wanted; + if (delta === 0) return guess; + guess -= delta; + } + return guess; +} + +/** + * Next local `hour:00:00` in `timezone` strictly after `nowMs`. + * If now lands exactly on that instant, return the following cycle. + */ +export function nextDailyResetAtMs(timezone: string, hour: number, nowMs: number): number { + const now = zonedParts(nowMs, timezone); + let date = { year: now.year, month: now.month, day: now.day }; + let next = zonedLocalToUtc(date.year, date.month, date.day, hour, 0, 0, timezone); + if (next <= nowMs) { + date = addCalendarDay(date.year, date.month, date.day); + next = zonedLocalToUtc(date.year, date.month, date.day, hour, 0, 0, timezone); + } + return next; +} + +export function parseTpdLimitFromText(text: string): number | null { + const m = /limit:\s*(\d+)/i.exec(text); + if (!m) return null; + const n = Number(m[1]); + return Number.isFinite(n) ? n : null; +} + +export function isTpdRateLimit(errorText: string | null | undefined): boolean { + return String(errorText || "") + .toLowerCase() + .includes("tpd rate limit"); +} + +export type TpdCooldownOptions = { + timezone?: unknown; + hour?: unknown; + nowMs?: number; + headerResetAtMs?: number | null; +}; + +/** + * Cooldown for a TPD 429. Header reset wins; else the node clock. + * Both missing → null (caller uses short 429, does not guess midnight). + */ +export function resolveTpdCooldownMs( + errorText: string | null | undefined, + options: TpdCooldownOptions = {}, +): number | null { + if (!isTpdRateLimit(errorText)) return null; + const now = options.nowMs ?? Date.now(); + if (typeof options.headerResetAtMs === "number" && options.headerResetAtMs > now) { + return options.headerResetAtMs - now; + } + if (typeof options.timezone === "string" && isValidResetHour(options.hour)) { + if (!nodeDailyResetConfigured(options.timezone, options.hour)) return null; + return nextDailyResetAtMs(options.timezone, options.hour, now) - now; + } + return null; +} diff --git a/open-sse/services/moonshotQuotaFetcher.ts b/open-sse/services/moonshotQuotaFetcher.ts new file mode 100644 index 0000000000..df80fe11b3 --- /dev/null +++ b/open-sse/services/moonshotQuotaFetcher.ts @@ -0,0 +1,229 @@ +/** + * moonshotQuotaFetcher.ts — Moonshot Open Platform balance quota fetcher + * + * GET {origin}/v1/users/me/balance + * { code: 0, data: { available_balance, voucher_balance, cash_balance } } + * + * Origin comes from the connection baseUrl (api.moonshot.cn or api.moonshot.ai). + * Do not hardcode .ai as a fallback for .cn keys. + * + * Cache: 60s in-memory. Registration: registerMoonshotQuotaFetcher() at startup. + */ + +import { toNumber } from "@/shared/utils/numeric"; +import { registerQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts"; +import { registerMonitorFetcher } from "./quotaMonitor.ts"; +import { throttleQuotaFetch } from "./quotaFetchThrottle.ts"; +import { + isMoonshotOpenPlatformConnection, + moonshotBalanceUrl, + resolveMoonshotOrigin, +} from "./usage/moonshotOpenPlatform.ts"; +import type { UsageQuota } from "./usage/quota.ts"; + +const CACHE_TTL_MS = 60_000; + +export interface MoonshotQuota extends QuotaInfo { + availableBalance: number; + voucherBalance: number; + cashBalance: number; + origin: string; + limitReached: boolean; +} + +interface CacheEntry { + quota: MoonshotQuota; + fetchedAt: number; +} + +const quotaCache = new Map(); + +const _cacheCleanup = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of quotaCache) { + if (now - entry.fetchedAt > CACHE_TTL_MS * 5) { + quotaCache.delete(key); + } + } +}, 5 * 60_000); + +if (typeof _cacheCleanup === "object" && "unref" in _cacheCleanup) { + (_cacheCleanup as { unref?: () => void }).unref?.(); +} + +function toRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function parseMoonshotQuotaResponse(data: unknown, origin: string): MoonshotQuota | null { + const obj = toRecord(data); + const code = obj.code; + if (code !== 0 && code !== undefined) return null; + const payload = toRecord(obj.data); + if (!("available_balance" in payload) && !("availableBalance" in payload)) return null; + const availableBalance = toNumber(payload.available_balance ?? payload.availableBalance, 0); + const voucherBalance = toNumber(payload.voucher_balance ?? payload.voucherBalance, 0); + const cashBalance = toNumber(payload.cash_balance ?? payload.cashBalance, 0); + const limitReached = availableBalance <= 0; + const percentUsed = limitReached ? 1 : 0; + return { + used: percentUsed * 100, + total: 100, + percentUsed, + resetAt: null, + availableBalance, + voucherBalance, + cashBalance, + origin, + limitReached, + windows: { balance: { percentUsed, resetAt: null } }, + }; +} + +function connectionApiKey(connection?: Record): string | null { + const apiKey = connection?.apiKey; + return typeof apiKey === "string" && apiKey.trim().length > 0 ? apiKey : null; +} + +export async function fetchMoonshotQuota( + connectionId: string, + connection?: Record +): Promise { + const cached = quotaCache.get(connectionId); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.quota; + } + + const apiKey = connectionApiKey(connection); + if (!apiKey) return null; + + const origin = resolveMoonshotOrigin({ + provider: typeof connection?.provider === "string" ? connection.provider : undefined, + providerSpecificData: connection?.providerSpecificData, + }); + if (!origin) return null; + + const url = moonshotBalanceUrl(origin); + const authHeader = ["Bearer", apiKey].join(" "); + + try { + await throttleQuotaFetch(); + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + Accept: "application/json", + }, + signal: AbortSignal.timeout(8_000), + }); + + if (response.status === 401 || response.status === 403) { + quotaCache.delete(connectionId); + return null; + } + if (!response.ok) return null; + + const data = await response.json(); + const quota = parseMoonshotQuotaResponse(data, origin); + if (!quota) return null; + quotaCache.set(connectionId, { quota, fetchedAt: Date.now() }); + return quota; + } catch { + return null; + } +} + +export function invalidateMoonshotQuotaCache(connectionId: string): void { + quotaCache.delete(connectionId); +} + +export type MoonshotUsageConnection = { + id?: string; + provider?: string; + apiKey?: string; + providerSpecificData?: unknown; +}; + +export async function getMoonshotOpenPlatformUsage( + connection: MoonshotUsageConnection +): Promise<{ + plan?: string; + quotas?: Record; + message?: string; + limitReached?: boolean; +}> { + const origin = resolveMoonshotOrigin(connection); + if (!origin) { + return { message: "Not a Moonshot Open Platform connection." }; + } + const quota = (await fetchMoonshotQuota(connection.id || "moonshot", { + apiKey: connection.apiKey, + provider: connection.provider, + providerSpecificData: connection.providerSpecificData, + })) as MoonshotQuota | null; + if (!quota) { + return { message: "Moonshot API key not available. Add a key to view usage." }; + } + const domestic = origin.includes("moonshot.cn"); + return { + plan: domestic ? "Kimi 开放平台(国内)" : "Kimi Open Platform", + quotas: buildMoonshotBalanceQuotas(quota, domestic ? "CNY" : "USD"), + limitReached: quota.limitReached, + }; +} + +function balanceQuota( + remaining: number, + remainingPercentage: number, + currency: string +): UsageQuota { + return { + used: 0, + total: 0, + remaining, + remainingPercentage, + resetAt: null, + unlimited: true, + currency, + }; +} + +function buildMoonshotBalanceQuotas( + quota: MoonshotQuota, + currency: string +): Record { + return { + available: balanceQuota(quota.availableBalance, quota.limitReached ? 0 : 100, currency), + voucher: balanceQuota(quota.voucherBalance, 100, currency), + cash: balanceQuota(quota.cashBalance, 100, currency), + }; +} + +export function registerMoonshotQuotaFetcher(): void { + registerQuotaFetcher("moonshot", fetchMoonshotQuota); + registerQuotaFetcher("kimi", fetchMoonshotQuota); + registerMonitorFetcher("moonshot", fetchMoonshotQuota); + registerMonitorFetcher("kimi", fetchMoonshotQuota); +} + +export function registerMoonshotFetchersForNodes( + nodes: Array<{ id?: string | null; prefix?: string | null; baseUrl?: string | null }> +): void { + for (const node of nodes) { + const origin = resolveMoonshotOrigin({}, node.baseUrl); + if (!origin) continue; + if (typeof node.id === "string" && node.id) { + registerQuotaFetcher(node.id, fetchMoonshotQuota); + registerMonitorFetcher(node.id, fetchMoonshotQuota); + } + if (typeof node.prefix === "string" && node.prefix) { + registerQuotaFetcher(node.prefix, fetchMoonshotQuota); + registerMonitorFetcher(node.prefix, fetchMoonshotQuota); + } + } +} + +export { isMoonshotOpenPlatformConnection }; diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index ea240340ea..62e96ce1bc 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -61,6 +61,8 @@ import { getQoderUsage, parseQoderUserStatusUsage } from "./usage/qoder.ts"; export { parseQoderUserStatusUsage } from "./usage/qoder.ts"; import { getOpencodeUsage } from "./usage/opencode.ts"; import { getDeepseekUsage } from "./usage/deepseek.ts"; +import { getMoonshotOpenPlatformUsage } from "./moonshotQuotaFetcher.ts"; +import { isMoonshotOpenPlatformConnection } from "./usage/moonshotOpenPlatform.ts"; import { getDevinCliUsage } from "./usage/devinCli.ts"; import { getBailianCodingPlanUsage } from "./usage/bailian.ts"; import { getVertexUsage } from "./usage/vertex.ts"; @@ -111,6 +113,10 @@ export async function getUsageForProvider( ) { const { id, provider, accessToken, apiKey, providerSpecificData, projectId, email } = connection; + if (isMoonshotOpenPlatformConnection(connection)) { + return await getMoonshotOpenPlatformUsage(connection); + } + switch (provider) { case "github": return await getGitHubUsage(accessToken, providerSpecificData); @@ -168,6 +174,9 @@ export async function getUsageForProvider( return await getNanoGptUsage(apiKey || ""); case "deepseek": return await getDeepseekUsage(id || "", apiKey || ""); + case "moonshot": + case "kimi": + return await getMoonshotOpenPlatformUsage(connection); case "openrouter": return await getOpenrouterUsage(id || "", apiKey || "", providerSpecificData); case "opencode": diff --git a/open-sse/services/usage/fetcherProviders.ts b/open-sse/services/usage/fetcherProviders.ts index bfcc5da710..05a3b47225 100644 --- a/open-sse/services/usage/fetcherProviders.ts +++ b/open-sse/services/usage/fetcherProviders.ts @@ -45,6 +45,8 @@ export const USAGE_FETCHER_PROVIDERS = [ "qwen-cloud-token-plan", "nanogpt", "deepseek", + "moonshot", + "kimi", "opencode", "opencode-zen", "xiaomi-mimo", diff --git a/open-sse/services/usage/moonshotOpenPlatform.ts b/open-sse/services/usage/moonshotOpenPlatform.ts new file mode 100644 index 0000000000..a652aacb36 --- /dev/null +++ b/open-sse/services/usage/moonshotOpenPlatform.ts @@ -0,0 +1,76 @@ +/** + * Moonshot Open Platform host recognition. + * + * Distinguishes prepaid Open Platform keys (api.moonshot.cn / api.moonshot.ai) + * from Kimi Coding Plan (api.kimi.com/coding). Custom compatible nodes are + * identified by baseUrl host, not by provider id (those ids are uuids). + */ + +import { moonshotProvider } from "../../config/providers/registry/moonshot/index.ts"; +import { kimiProvider } from "../../config/providers/registry/kimi/index.ts"; + +export const MOONSHOT_OPEN_PLATFORM_HOSTS: ReadonlySet = new Set([ + "api.moonshot.cn", + "api.moonshot.ai", +]); + +export type MoonshotOriginConnection = { + provider?: string; + providerSpecificData?: unknown; +}; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export function parseMoonshotOrigin(baseUrl: string | null | undefined): string | null { + if (typeof baseUrl !== "string" || baseUrl.trim() === "") return null; + let url: URL; + try { + url = new URL(baseUrl.trim()); + } catch { + return null; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + const host = url.hostname.toLowerCase(); + if (!MOONSHOT_OPEN_PLATFORM_HOSTS.has(host)) return null; + const port = url.port ? `:${url.port}` : ""; + return `${url.protocol}//${host}${port}`; +} + +export function moonshotBalanceUrl(origin: string): string { + return `${origin}/v1/users/me/balance`; +} + +function registryDefaultOrigin(provider: string | undefined): string | null { + if (provider === "moonshot") return parseMoonshotOrigin(moonshotProvider.baseUrl); + if (provider === "kimi") return parseMoonshotOrigin(kimiProvider.baseUrl); + return null; +} + +export function resolveMoonshotOrigin( + connection: MoonshotOriginConnection, + nodeBaseUrl?: string | null, +): string | null { + const psd = asRecord(connection.providerSpecificData); + const fromPsd = typeof psd.baseUrl === "string" ? parseMoonshotOrigin(psd.baseUrl) : null; + if (fromPsd) return fromPsd; + const fromNode = parseMoonshotOrigin(nodeBaseUrl); + if (fromNode) return fromNode; + return registryDefaultOrigin(connection.provider); +} + +export function isMoonshotOpenPlatformConnection( + connection: MoonshotOriginConnection, + nodeBaseUrl?: string | null, +): boolean { + return resolveMoonshotOrigin(connection, nodeBaseUrl) !== null; +} + +/** Account-level empty wallet on Open Platform. Narrower than any compatible 429. */ +export function isMoonshotAccountBalanceExhausted(errorText: string | null | undefined): boolean { + const lower = String(errorText || "").toLowerCase(); + return lower.includes("insufficient balance") || lower.includes("exceeded_current_quota"); +} diff --git a/open-sse/services/usage/supportedProviders.ts b/open-sse/services/usage/supportedProviders.ts index dc088fa1c4..b8e0481b86 100644 --- a/open-sse/services/usage/supportedProviders.ts +++ b/open-sse/services/usage/supportedProviders.ts @@ -41,6 +41,8 @@ export const USAGE_SUPPORTED_PROVIDERS: readonly string[] = [ "crof", "nanogpt", "deepseek", + "moonshot", + "kimi", "xiaomi-mimo", "xiaomi-mimo-token-plan", "vertex", diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx index 026aa324fa..dc1966677f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx @@ -15,6 +15,8 @@ interface EditCompatibleNodeModalNode { chatPath?: string; modelsPath?: string; iconUrl?: string; + dailyQuotaResetTimezone?: string | null; + dailyQuotaResetHour?: number | null; providerSpecificData?: Record; } @@ -48,6 +50,8 @@ export default function EditCompatibleNodeModal({ consoleApiKey: "", newApiUserId: "", quotaPerUnit: "", + dailyQuotaResetTimezone: "", + dailyQuotaResetHour: "", }); const [saving, setSaving] = useState(false); const [checkKey, setCheckKey] = useState(""); @@ -98,6 +102,11 @@ export default function EditCompatibleNodeModal({ consoleApiKey: typeof psd.consoleApiKey === "string" ? psd.consoleApiKey : "", newApiUserId: typeof psd.newApiUserId === "string" ? psd.newApiUserId : "", quotaPerUnit: typeof psd.quotaPerUnit === "number" ? String(psd.quotaPerUnit) : "", + dailyQuotaResetTimezone: node.dailyQuotaResetTimezone || "", + dailyQuotaResetHour: + node.dailyQuotaResetHour === 0 || node.dailyQuotaResetHour + ? String(node.dailyQuotaResetHour) + : "", }); setSaveError(null); setIconUrlError(null); @@ -141,6 +150,10 @@ export default function EditCompatibleNodeModal({ modelsPath: isCcCompatible ? "" : formData.modelsPath, iconUrl: formData.iconUrl.trim(), }; + const tz = formData.dailyQuotaResetTimezone.trim(); + payload.dailyQuotaResetTimezone = tz || null; + const hourRaw = formData.dailyQuotaResetHour.trim(); + payload.dailyQuotaResetHour = hourRaw === "" ? null : Number(hourRaw); if (!isAnthropic) { payload.apiType = formData.apiType; } @@ -345,6 +358,22 @@ export default function EditCompatibleNodeModal({ hint={t("modelsPathHint")} /> )} + + setFormData({ ...formData, dailyQuotaResetTimezone: e.target.value }) + } + placeholder="Asia/Shanghai" + hint={t("dailyQuotaResetTimezoneHint")} + /> + setFormData({ ...formData, dailyQuotaResetHour: e.target.value })} + placeholder="0" + hint={t("dailyQuotaResetHourHint")} + /> )}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index d5f7fa0de0..eda5308eb4 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -16,8 +16,8 @@ import { } from "./utils"; import Card from "@/shared/components/Card"; import { CardSkeleton } from "@/shared/components/Loading"; -import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; import { pickDisplayValue } from "@/shared/utils/maskEmail"; +import { supportsProviderQuota, isProviderQuotaVisible } from "@/shared/utils/providerQuotaVisibility"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import { useNotificationStore } from "@/store/notificationStore"; @@ -32,7 +32,6 @@ import { formatAutoRefreshCountdown } from "./formatters"; import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback"; import { compareTr } from "@/shared/utils/turkishText"; import { fetchWithTimeout } from "@/shared/utils/fetchTimeout"; -import { isProviderQuotaVisible } from "@/shared/utils/providerQuotaVisibility"; // Bound the two first-paint requests so a stalled connection cannot wedge // `initialLoading` on `true` and freeze the quota page on its skeleton forever @@ -529,7 +528,7 @@ export default function ProviderLimits({ connections.filter( (conn) => isProviderQuotaVisible(conn) && - USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && + supportsProviderQuota(conn.provider, conn) && (conn.authType === "oauth" || conn.authType === "apikey") ), [connections] diff --git a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx index 37b11ff286..3e91bd3526 100644 --- a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx +++ b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import Card from "@/shared/components/Card"; import ProviderIcon from "@/shared/components/ProviderIcon"; -import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; +import { supportsProviderQuota } from "@/shared/utils/providerQuotaVisibility"; import QuotaMiniBar from "../dashboard/usage/components/ProviderLimits/QuotaMiniBar"; import { PROVIDER_LABEL } from "../dashboard/usage/components/ProviderLimits/constants"; import { translateUsageOrFallback } from "../dashboard/usage/components/ProviderLimits/i18nFallback"; @@ -25,6 +25,7 @@ type Connection = { name?: string; displayName?: string; email?: string; + providerSpecificData?: unknown; }; type QuotaData = Record; @@ -178,7 +179,7 @@ export default function ProviderQuotaWidget({ const quotaResponseData = quotasResponse.ok ? await quotasResponse.json() : {}; const relevant = ((connectionData.connections || []) as Connection[]).filter( (connection) => - USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) && + supportsProviderQuota(connection.provider, connection) && (connection.authType === "oauth" || connection.authType === "apikey") ); setConnections(relevant); diff --git a/src/app/api/provider-nodes/[id]/route.ts b/src/app/api/provider-nodes/[id]/route.ts index eef4803372..19bd524ef8 100644 --- a/src/app/api/provider-nodes/[id]/route.ts +++ b/src/app/api/provider-nodes/[id]/route.ts @@ -56,7 +56,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { name, prefix, apiType, baseUrl, chatPath, modelsPath, customHeaders, iconUrl } = + const { name, prefix, apiType, baseUrl, chatPath, modelsPath, customHeaders, iconUrl, dailyQuotaResetTimezone, dailyQuotaResetHour } = validation.data; const node: any = await getProviderNodeById(id); @@ -98,6 +98,9 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: // previously stored custom icon. iconUrl: iconUrl?.trim() || null, customHeaders: customHeaders || null, + dailyQuotaResetTimezone: dailyQuotaResetTimezone?.trim() || null, + dailyQuotaResetHour: + dailyQuotaResetHour === 0 || dailyQuotaResetHour != null ? dailyQuotaResetHour : null, }; if (node.type === "openai-compatible") { @@ -106,6 +109,21 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: const updated = await updateProviderNode(id, updates); + try { + const { registerMoonshotFetchersForNodes } = await import( + "@omniroute/open-sse/services/moonshotQuotaFetcher.ts" + ); + registerMoonshotFetchersForNodes([ + { + id: typeof updated?.id === "string" ? updated.id : id, + prefix: prefix.trim(), + baseUrl: sanitizedBaseUrl, + }, + ]); + } catch (error) { + console.warn("Moonshot fetcher re-register after node update skipped:", error); + } + const connections = await getProviderConnections({ provider: id }); await Promise.all( connections.flatMap((connectionRaw) => { diff --git a/src/app/api/provider-nodes/route.ts b/src/app/api/provider-nodes/route.ts index a11180f505..d60e3b299a 100644 --- a/src/app/api/provider-nodes/route.ts +++ b/src/app/api/provider-nodes/route.ts @@ -48,6 +48,27 @@ function sanitizeVibeProxyBaseUrl(baseUrl: string) { return `${base}/v1`; } +async function registerMoonshotFetchersForCreatedNode(node: { + id?: unknown; + prefix?: unknown; + baseUrl?: unknown; +}): Promise { + try { + const { registerMoonshotFetchersForNodes } = await import( + "@omniroute/open-sse/services/moonshotQuotaFetcher.ts" + ); + registerMoonshotFetchersForNodes([ + { + id: typeof node.id === "string" ? node.id : null, + prefix: typeof node.prefix === "string" ? node.prefix : null, + baseUrl: typeof node.baseUrl === "string" ? node.baseUrl : null, + }, + ]); + } catch (error) { + console.warn("Moonshot fetcher register after node create skipped:", error); + } +} + function sanitizeAnthropicBaseUrl(baseUrl: string) { return (baseUrl || "") .trim() @@ -126,6 +147,8 @@ export async function POST(request) { modelsPath, customHeaders, iconUrl, + dailyQuotaResetTimezone, + dailyQuotaResetHour, } = validation.data; if (preset === "vibeproxy-openai") { @@ -145,7 +168,11 @@ export async function POST(request) { modelsPath: modelsPath || null, iconUrl: iconUrl?.trim() || null, customHeaders: customHeaders || null, + dailyQuotaResetTimezone: dailyQuotaResetTimezone?.trim() || null, + dailyQuotaResetHour: + dailyQuotaResetHour === 0 || dailyQuotaResetHour != null ? dailyQuotaResetHour : null, }); + await registerMoonshotFetchersForCreatedNode(node); return NextResponse.json({ node }, { status: 201 }); } @@ -170,7 +197,11 @@ export async function POST(request) { modelsPath: modelsPath || null, iconUrl: iconUrl?.trim() || null, customHeaders: customHeaders || null, + dailyQuotaResetTimezone: dailyQuotaResetTimezone?.trim() || null, + dailyQuotaResetHour: + dailyQuotaResetHour === 0 || dailyQuotaResetHour != null ? dailyQuotaResetHour : null, }); + await registerMoonshotFetchersForCreatedNode(node); return NextResponse.json({ node }, { status: 201 }); } @@ -200,7 +231,11 @@ export async function POST(request) { modelsPath: compatMode === "cc" ? null : modelsPath || null, iconUrl: iconUrl?.trim() || null, customHeaders: customHeaders || null, + dailyQuotaResetTimezone: dailyQuotaResetTimezone?.trim() || null, + dailyQuotaResetHour: + dailyQuotaResetHour === 0 || dailyQuotaResetHour != null ? dailyQuotaResetHour : null, }); + await registerMoonshotFetchersForCreatedNode(node); return NextResponse.json({ node }, { status: 201 }); } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index a2d2c4acf9..bdf4b7a4c7 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "عنوان URL للأيقونة", "iconUrlHint": "اختياري. عنوان URL للصورة المعروضة كأيقونة لهذا المزود.", "iconUrlInvalid": "رابط الأيقونة غير صالح. استخدم http(s):// أو data:image/*;base64 رابط.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 131a9b212b..543b6b60b0 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "İkon URL-i", "iconUrlHint": "İstəyə bağlı. Bu provayderin ikonu kimi göstərilən şəkil URL-i.", "iconUrlInvalid": "Yanlış ikon URL-si. http(s):// və ya data:image/*;base64 URL istifadə edin.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index f51cea18c1..3944642aff 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL адрес на икона", "iconUrlHint": "По избор. URL адрес на изображение, показвано като икона на този доставчик.", "iconUrlInvalid": "Невалиден URL на иконата. Използвайте http(s):// или data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 4dacb5dd56..8f6afadff3 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "আইকন URL", "iconUrlHint": "ঐচ্ছিক। এই প্রদানকারীর আইকন হিসেবে দেখানোর জন্য ছবির URL।", "iconUrlInvalid": "অবৈধ আইকন URL। একটি http(s):// অথবা data:image/*;base64 URL ব্যবহার করুন।", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 2450daa9ff..29af05425a 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL ikony", "iconUrlHint": "Volitelné. URL obrázku zobrazeného jako ikona tohoto poskytovatele.", "iconUrlInvalid": "Neplatná URL ikony. Použijte http(s):// nebo data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 4dd4699f43..bea97fe340 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Ikon-URL", "iconUrlHint": "Valgfrit. Billed-URL, der vises som denne udbyders ikon.", "iconUrlInvalid": "Ugyldig ikon-URL. Brug en http(s):// eller data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 0fa6991704..d0c0322ac1 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Icon-URL", "iconUrlHint": "Optional. Bild-URL, die als Icon dieses Anbieters angezeigt wird.", "iconUrlInvalid": "Ungültige Icon-URL. Verwenden Sie eine http(s):// oder data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "AC-Prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index e1b0adcb11..e9643f3511 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5251,6 +5251,10 @@ "iconUrlLabel": "Icon URL", "iconUrlHint": "Optional. Image URL shown as this provider's icon.", "iconUrlInvalid": "Invalid icon URL. Use an http(s):// or data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 99f66790a4..ba61b59daa 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Icon URL", "iconUrlHint": "Optional. Image URL shown as this provider's icon.", "iconUrlInvalid": "URL de icono no válida. Utilice una URL http(s):// o data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "producto ac", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 176e89c4d2..79c0ec653f 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL آیکون", "iconUrlHint": "اختیاری. URL تصویری که به عنوان آیکون این ارائه‌دهنده نمایش داده می‌شود.", "iconUrlInvalid": "آدرس آیکون نامعتبر است. از آدرس http(s):// یا data:image/*;base64 استفاده کنید.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 8c7c2c9c98..c83a56cb2c 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Kuvakkeen URL-osoite", "iconUrlHint": "Valinnainen. Kuvan URL-osoite, joka näytetään tämän tarjoajan kuvakkeena.", "iconUrlInvalid": "Virheellinen kuvakkeen URL. Käytä http(s):// tai data:image/*;base64 URL:ia.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index c17cb242a0..1bb74a6c87 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL de l'icône", "iconUrlHint": "Facultatif. URL de l'image affichée comme icône de ce fournisseur.", "iconUrlInvalid": "URL d'icône invalide. Utilisez une URL http(s):// ou data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 00196e5ad8..7fbc8ac29b 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "આઇકન URL", "iconUrlHint": "વૈકલ્પિક. આ પ્રદાતાના આઇકન તરીકે દર્શાવેલ છબી URL.", "iconUrlInvalid": "અમાન્ય આઇકન URL. http(s):// અથવા data:image/*;base64 URL નો ઉપયોગ કરો.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 19d7e96e65..66bea20bb8 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "כתובת URL של סמל", "iconUrlHint": "אופציונלי. כתובת URL של תמונה שתוצג כסמל של ספק זה.", "iconUrlInvalid": "כתובת ה-URL של האייקון אינה חוקית. השתמש ב-http(s):// או ב-data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 2f9b11ace2..f45c0fad90 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "आइकन URL", "iconUrlHint": "वैकल्पिक। इस प्रदाता के आइकन के रूप में दिखाया जाने वाला छवि URL।", "iconUrlInvalid": "अमान्य आइकन URL। http(s):// या data:image/*;base64 URL का उपयोग करें।", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "एसी-उत्पाद", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 339507ca3c..b1275355b9 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Ikon URL-címe", "iconUrlHint": "Opcionális. A szolgáltató ikonjaként megjelenő kép URL-címe.", "iconUrlInvalid": "Érvénytelen ikon URL. Használjon http(s):// vagy data:image/*;base64 URL-t.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index bf2f06a265..474a26973d 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL Ikon", "iconUrlHint": "Opsional. URL gambar yang ditampilkan sebagai ikon penyedia ini.", "iconUrlInvalid": "URL ikon tidak valid. Gunakan URL http(s):// atau data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 855d5b3ecb..86e1226241 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL Icona", "iconUrlHint": "Opzionale. URL dell'immagine mostrata come icona di questo provider.", "iconUrlInvalid": "URL dell'icona non valida. Usa un URL http(s):// o data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 6e734c226e..ab2f52c289 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "アイコンURL", "iconUrlHint": "任意。このプロバイダーのアイコンとして表示される画像URL。", "iconUrlInvalid": "無効なアイコンURLです。http(s)://またはdata:image/*;base64 URLを使用してください。", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index fb12ad0c99..ad26677619 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "아이콘 URL", "iconUrlHint": "선택 사항. 이 제공자의 아이콘으로 표시될 이미지 URL입니다.", "iconUrlInvalid": "잘못된 아이콘 URL입니다. http(s):// 또는 data:image/*;base64 URL을 사용하세요.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 8817c1c87f..7f5ee425c9 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "आयकॉन URL", "iconUrlHint": "पर्यायी. या प्रदात्याचा आयकॉन म्हणून दर्शविलेली इमेज URL.", "iconUrlInvalid": "अवैध आयकॉन URL. http(s):// किंवा data:image/*;base64 URL वापरा.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 186db22e3a..a780d09bcb 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL Ikon", "iconUrlHint": "Pilihan. URL imej yang ditunjukkan sebagai ikon penyedia ini.", "iconUrlInvalid": "URL ikon tidak sah. Gunakan URL http(s):// atau data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index edf66b80a1..706a5ccd35 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Pictogram-URL", "iconUrlHint": "Optioneel. Afbeeldings-URL die wordt getoond als het pictogram van deze provider.", "iconUrlInvalid": "Ongeldige pictogram-URL. Gebruik een http(s):// of data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index d4e45081b6..9576e4b6a6 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Ikon-URL", "iconUrlHint": "Valgfritt. Bilde-URL som vises som denne leverandørens ikon.", "iconUrlInvalid": "Ugyldig ikon-URL. Bruk en http(s):// eller data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 6f9f973ec1..856b906cfb 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL ng Icon", "iconUrlHint": "Opsyonal. URL ng larawan na ipinapakita bilang icon ng provider na ito.", "iconUrlInvalid": "Hindi wastong URL ng icon. Gumamit ng http(s):// o data:image/*;base64 na URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 002c11a98d..b9ecc290db 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL ikony", "iconUrlHint": "Opcjonalnie. URL obrazu wyświetlany jako ikona tego provider.", "iconUrlInvalid": "Nieprawidłowy adres URL ikony. Użyj adresu http(s):// lub data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 5717bff5cc..e65916a54d 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -5252,6 +5252,10 @@ "iconUrlLabel": "URL do ícone", "iconUrlHint": "Opcional. URL da imagem exibida como ícone deste provedor.", "iconUrlInvalid": "URL do ícone inválido. Use um URL http(s):// ou data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 4722209d02..8e518e2e0e 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL do ícone", "iconUrlHint": "Opcional. URL da imagem mostrada como ícone deste provedor.", "iconUrlInvalid": "URL do ícone inválido. Utilize uma URL http(s):// ou data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index a0d82ca4d5..0bfdea697e 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL pictogramă", "iconUrlHint": "Opțional. URL-ul imaginii afișate ca pictogramă a acestui furnizor.", "iconUrlInvalid": "URL-ul iconului este invalid. Folosiți un URL http(s):// sau data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 4a7646aaef..c63a9fab99 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL-адрес иконки", "iconUrlHint": "Необязательно. URL-адрес изображения, используемого в качестве иконки этого провайдера.", "iconUrlInvalid": "Неверный URL значка. Используйте URL-адрес http(s):// или data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-прод", "openaiPrefixPlaceholder": "oc-прод", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 66a01e79e2..de5778a2cd 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL ikony", "iconUrlHint": "Voliteľné. URL obrázka zobrazeného ako ikona tohto poskytovateľa.", "iconUrlInvalid": "Neplatná URL ikony. Použite http(s):// alebo data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index d8ca20a04d..0408b0806b 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Ikon-URL", "iconUrlHint": "Valfritt. Bild-URL som visas som denna leverantörs ikon.", "iconUrlInvalid": "Ogiltig ikon-URL. Använd en http(s):// eller data:image/*;base64-URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 5507c02543..88b9200348 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL ya Aikoni", "iconUrlHint": "Si lazima. URL ya picha inayoonyeshwa kama aikoni ya mtoa huduma huyu.", "iconUrlInvalid": "URL ya ikoni si sahihi. Tumia http(s):// au data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index a5e601ae93..95da2289ce 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "சின்னத்தின் URL", "iconUrlHint": "விருப்பத்திற்குரியது. இந்த வழங்குநரின் சின்னமாக காட்டப்படும் பட URL.", "iconUrlInvalid": "தவறான ஐகான் URL. http(s):// அல்லது data:image/*;base64 URL ஐப் பயன்படுத்தவும்.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 69a51f4d6a..3526a50b41 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "ఐకాన్ URL", "iconUrlHint": "ఐచ్ఛికం. ఈ ప్రొవైడర్ ఐకాన్‌గా చూపబడే చిత్రం URL.", "iconUrlInvalid": "చెల్లని ఐకాన్ URL. http(s):// లేదా data:image/*;base64 URL ఉపయోగించండి.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index b43dda761c..e19eb88c20 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL ไอคอน", "iconUrlHint": "ไม่บังคับ URL รูปภาพที่จะแสดงเป็นไอคอนของผู้ให้บริการรายนี้", "iconUrlInvalid": "URL ไอคอนไม่ถูกต้อง ใช้ http(s):// หรือ data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-ผลิตภัณฑ์", "openaiPrefixPlaceholder": "oc-ผลิตภัณฑ์", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 31e48fb100..12b3973933 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Simge URL'si", "iconUrlHint": "İsteğe bağlı. Bu sağlayıcının simgesi olarak gösterilen görsel URL'si.", "iconUrlInvalid": "Geçersiz simge URL'si. http(s):// veya data:image/*;base64 URL'si kullanın.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 62d8db3226..fd9a0732d3 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL-адреса іконки", "iconUrlHint": "Необов'язково. URL-адреса зображення, що відображається як іконка цього провайдера.", "iconUrlInvalid": "Недійсне URL-адреса значка. Використовуйте http(s):// або data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 1a4f1de826..02dba16c45 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "آئیکن URL", "iconUrlHint": "اختیاری۔ تصویر کا URL جو اس فراہم کنندہ کے آئیکن کے طور پر دکھایا گیا ہے۔", "iconUrlInvalid": "غلط آئیکن یو آر ایل۔ http(s):// یا data:image/*;base64 یو آر ایل استعمال کریں۔", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 0a8428ecdf..cf7235f61b 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -5252,6 +5252,10 @@ "iconUrlLabel": "URL biểu tượng", "iconUrlHint": "Tùy chọn. URL hình ảnh được hiển thị làm biểu tượng của nhà cung cấp này.", "iconUrlInvalid": "URL biểu tượng không hợp lệ. Sử dụng http(s):// hoặc data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index facdccc8c2..e53f191cf9 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "图标 URL", "iconUrlHint": "可选。显示为此服务商图标的图片 URL。", "iconUrlInvalid": "无效的图标 URL。请使用 http(s):// 或 data:image/*;base64 URL。", + "dailyQuotaResetTimezoneLabel": "每日额度重置时区", + "dailyQuotaResetTimezoneHint": "可选。上游不返回 X-RateLimit-Reset 时使用的 IANA 时区。留空则只做短冷却。", + "dailyQuotaResetHourLabel": "每日额度重置小时", + "dailyQuotaResetHourHint": "上述时区的本地小时,0-23。留空表示不配置节点级时钟。", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 3d10722987..2cddce94b2 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "圖示網址", "iconUrlHint": "選用。顯示為此提供者圖示的圖片網址。", "iconUrlInvalid": "無效的圖示網址。請使用 http(s):// 或 data:image/*;base64 URL。", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 069b8740b8..e3ae959118 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -282,6 +282,7 @@ export async function registerQuotaFetchers(): Promise { { registerQwenTokenPlanQuotaFetcher }, { registerCrofUsageFetcher }, { registerDeepseekQuotaFetcher }, + { registerMoonshotQuotaFetcher, registerMoonshotFetchersForNodes }, { registerOpenrouterQuotaFetcher }, { registerOpencodeQuotaFetcher }, { registerGrokWebQuotaFetcher }, @@ -292,6 +293,7 @@ export async function registerQuotaFetchers(): Promise { import("@omniroute/open-sse/services/qwenTokenPlanQuotaFetcher"), import("@omniroute/open-sse/services/crofUsageFetcher"), import("@omniroute/open-sse/services/deepseekQuotaFetcher"), + import("@omniroute/open-sse/services/moonshotQuotaFetcher"), import("@omniroute/open-sse/services/openrouterQuotaFetcher"), import("@omniroute/open-sse/services/opencodeQuotaFetcher"), import("@omniroute/open-sse/services/grokQuotaFetcher"), @@ -303,6 +305,20 @@ export async function registerQuotaFetchers(): Promise { registerQwenTokenPlanQuotaFetcher(); registerCrofUsageFetcher(); registerDeepseekQuotaFetcher(); + registerMoonshotQuotaFetcher(); + try { + const { getProviderNodes } = await import("@/lib/db/providers"); + const nodes = await getProviderNodes(); + registerMoonshotFetchersForNodes( + (Array.isArray(nodes) ? nodes : []).map((node) => ({ + id: typeof node.id === "string" ? node.id : null, + prefix: typeof node.prefix === "string" ? node.prefix : null, + baseUrl: typeof node.baseUrl === "string" ? node.baseUrl : null, + })), + ); + } catch (error) { + console.warn("[STARTUP] Moonshot custom-node fetcher scan skipped:", error); + } registerOpenrouterQuotaFetcher(); registerOpencodeQuotaFetcher(); registerGrokWebQuotaFetcher(); diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index 498be0f9bc..f53cd58070 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -516,6 +516,11 @@ function isSchemaAlreadyApplied( db.prepare("SELECT 1 FROM provider_connections WHERE provider = 'freepik' LIMIT 1").get() == null ); + case "172": + return ( + hasColumn(db, "provider_nodes", "daily_quota_reset_timezone") && + hasColumn(db, "provider_nodes", "daily_quota_reset_hour") + ); default: return false; } diff --git a/src/lib/db/migrations/172_provider_node_daily_quota_reset.sql b/src/lib/db/migrations/172_provider_node_daily_quota_reset.sql new file mode 100644 index 0000000000..969df2e0fb --- /dev/null +++ b/src/lib/db/migrations/172_provider_node_daily_quota_reset.sql @@ -0,0 +1,6 @@ +-- 172: per-node daily quota reset clock (IANA timezone + local hour). +-- Used by TPD cooldown when upstream omits X-RateLimit-Reset. +-- Both columns nullable: empty = operator has not configured a clock. + +ALTER TABLE provider_nodes ADD COLUMN daily_quota_reset_timezone TEXT; +ALTER TABLE provider_nodes ADD COLUMN daily_quota_reset_hour INTEGER; diff --git a/src/lib/db/providers/nodes.ts b/src/lib/db/providers/nodes.ts index 7c9a251a78..3bc975db92 100644 --- a/src/lib/db/providers/nodes.ts +++ b/src/lib/db/providers/nodes.ts @@ -9,6 +9,25 @@ import { backupDbFile } from "../backup"; import { invalidateDbCache } from "../readCache"; import { toRecord, type JsonRecord } from "./columns"; +function normalizeDailyQuotaResetHour(value: unknown): number | null { + return value === 0 || value ? Number(value) : null; +} + +function withParsedCustomHeaders(node: JsonRecord, storedJson: string | null): JsonRecord { + const result: JsonRecord = { ...node }; + if (storedJson) { + try { + result.customHeaders = JSON.parse(storedJson); + } catch { + result.customHeaders = null; + } + } else { + result.customHeaders = null; + } + delete result.customHeadersJson; + return result; +} + interface StatementLike { all: (...params: unknown[]) => TRow[]; get: (...params: unknown[]) => TRow | undefined; @@ -88,32 +107,23 @@ export async function createProviderNode(data: JsonRecord) { // Optional operator-supplied remote icon URL (#2166) — plain TEXT, no JSON parsing needed. iconUrl: data.iconUrl || null, customHeadersJson, + dailyQuotaResetTimezone: data.dailyQuotaResetTimezone || null, + dailyQuotaResetHour: normalizeDailyQuotaResetHour(data.dailyQuotaResetHour), createdAt: now, updatedAt: now, }; db.prepare( ` - INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, chat_path, models_path, icon_url, custom_headers_json, created_at, updated_at) - VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @chatPath, @modelsPath, @iconUrl, @customHeadersJson, @createdAt, @updatedAt) + INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, chat_path, models_path, icon_url, custom_headers_json, daily_quota_reset_timezone, daily_quota_reset_hour, created_at, updated_at) + VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @chatPath, @modelsPath, @iconUrl, @customHeadersJson, @dailyQuotaResetTimezone, @dailyQuotaResetHour, @createdAt, @updatedAt) ` ).run(node); backupDbFile("pre-write"); invalidateDbCache("nodes"); - const result: JsonRecord = { ...node }; - if (customHeadersJson) { - try { - result.customHeaders = JSON.parse(customHeadersJson); - } catch { - result.customHeaders = null; - } - } else { - result.customHeaders = null; - } - delete result.customHeadersJson; - return result; + return withParsedCustomHeaders({ ...node }, customHeadersJson); } export async function updateProviderNode(id: string, data: JsonRecord) { @@ -144,7 +154,10 @@ export async function updateProviderNode(id: string, data: JsonRecord) { UPDATE provider_nodes SET type = @type, name = @name, prefix = @prefix, api_type = @apiType, base_url = @baseUrl, chat_path = @chatPath, models_path = @modelsPath, icon_url = @iconUrl, - custom_headers_json = @customHeadersJson, updated_at = @updatedAt + custom_headers_json = @customHeadersJson, + daily_quota_reset_timezone = @dailyQuotaResetTimezone, + daily_quota_reset_hour = @dailyQuotaResetHour, + updated_at = @updatedAt WHERE id = @id ` ).run({ @@ -160,25 +173,15 @@ export async function updateProviderNode(id: string, data: JsonRecord) { // stored custom icon when the caller submits an empty value. iconUrl: merged["iconUrl"] || null, customHeadersJson: merged["customHeadersJson"] || null, + dailyQuotaResetTimezone: merged["dailyQuotaResetTimezone"] || null, + dailyQuotaResetHour: normalizeDailyQuotaResetHour(merged["dailyQuotaResetHour"]), updatedAt: merged["updatedAt"], }); backupDbFile("pre-write"); invalidateDbCache("nodes"); - const result: JsonRecord = { ...merged }; - const storedJson = merged["customHeadersJson"] as string | null; - if (storedJson) { - try { - result.customHeaders = JSON.parse(storedJson); - } catch { - result.customHeaders = null; - } - } else { - result.customHeaders = null; - } - delete result.customHeadersJson; - return result; + return withParsedCustomHeaders(merged, (merged["customHeadersJson"] as string | null) ?? null); } export async function deleteProviderNode(id: string) { diff --git a/src/lib/usage/apiKeySelfService.ts b/src/lib/usage/apiKeySelfService.ts index 2e9dc9ffa7..7bc2d440ca 100644 --- a/src/lib/usage/apiKeySelfService.ts +++ b/src/lib/usage/apiKeySelfService.ts @@ -1,5 +1,5 @@ import { hasSelfAccountQuotaScope, hasSelfUsageScope } from "@/shared/constants/selfServiceScopes"; -import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; +import { supportsProviderQuota } from "@/shared/utils/providerQuotaVisibility"; type JsonRecord = Record; type DateLike = number | string | Date | null | undefined; @@ -64,6 +64,7 @@ interface AccountQuotaConnection { id: string; provider: string; lookupFailed?: boolean; + providerSpecificData?: unknown; } function toNumber(value: unknown, fallback = 0): number { @@ -208,11 +209,14 @@ function normalizePlan(value: unknown): unknown { return undefined; } -function isSupportedProvider(provider: string): boolean { - return USAGE_SUPPORTED_PROVIDERS.includes(provider as (typeof USAGE_SUPPORTED_PROVIDERS)[number]); +function isSupportedProvider( + provider: string, + connection?: { provider?: string; providerSpecificData?: unknown }, +): boolean { + return supportsProviderQuota(provider, connection); } -function getConnectionIdentity(value: unknown): { id: string; provider: string } | null { +function getConnectionIdentity(value: unknown): AccountQuotaConnection | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const record = value as JsonRecord; if (record.isActive === false) return null; @@ -221,7 +225,11 @@ function getConnectionIdentity(value: unknown): { id: string; provider: string } const provider = typeof record.provider === "string" ? record.provider : ""; if (!id || !provider) return null; - return { id, provider }; + return { + id, + provider, + providerSpecificData: record.providerSpecificData, + }; } async function listAccountQuotaConnections( @@ -297,7 +305,7 @@ async function resolveConnectionAccountQuota( }; } - if (!isSupportedProvider(connection.provider)) { + if (!isSupportedProvider(connection.provider, connection)) { return { provider: connection.provider, connectionId: connection.id, diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 9a36d5e491..2e0b8895da 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -16,7 +16,7 @@ import { setQuotaCache } from "@/domain/quotaCache"; import { buildClaudeExtraUsageConnectionUpdate } from "@/lib/providers/claudeExtraUsage"; import { clearRecoveredProviderState } from "@/sse/services/auth"; import { getMachineId } from "@/shared/utils/machine"; -import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; +import { supportsProviderQuota } from "@/shared/utils/providerQuotaVisibility"; import { mergeProviderLimitsCacheEntry, toProviderLimitsCacheEntry } from "./providerLimitsCache"; import { getCredentialRefreshExecutor } from "@omniroute/open-sse/executors/credential.ts"; import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts"; @@ -174,19 +174,14 @@ function shouldRefreshProviderLimitsCache( } export function isSupportedUsageConnection(connection: ProviderConnectionLike | null): boolean { - if ( - !connection || - !connection.provider || - !USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) - ) { - return false; - } + if (!connection?.provider) return false; - if (connection.authType === "oauth") return true; - return ( - (connection.authType === "apikey" || connection.authType === "api_key") && - PROVIDER_LIMITS_APIKEY_PROVIDERS.has(connection.provider) - ); + if (connection.authType === "oauth") { + return supportsProviderQuota(connection.provider, connection); + } + if (connection.authType !== "apikey" && connection.authType !== "api_key") return false; + if (PROVIDER_LIMITS_APIKEY_PROVIDERS.has(connection.provider)) return true; + return supportsProviderQuota(connection.provider, connection); } function withStatus(error: Error, status: number): Error & { status: number } { diff --git a/src/shared/utils/classify429.ts b/src/shared/utils/classify429.ts index 03b6e41658..d482334778 100644 --- a/src/shared/utils/classify429.ts +++ b/src/shared/utils/classify429.ts @@ -91,6 +91,14 @@ const QUOTA_PATTERNS: ReadonlyArray = [ // Trailing punctuation/whitespace before the closing quote is tolerated // because real API responses may include a period or trailing space. /"error"\s*:\s*"usage limit reached[.\s]*"/i, + + // Moonshot Open Platform organization TPD (tokens-per-day). Live body: + // "request reached organization TPD rate limit, current: N, limit: M". + // Do not use a bare /TPD/ — too wide. Limit is read from the body, never + // hardcoded (Tier0=1.5M, Tier1+=unlimited). + /organization TPD rate limit/i, + /\bTPD rate limit\b/i, + /insufficient balance/i, ]; /** @@ -155,6 +163,9 @@ const TERMINAL_QUOTA_PATTERNS: ReadonlyArray = [ /individual quota reached/i, /enable overages/i, /daily free allocation/i, + /organization TPD rate limit/i, + /\bTPD rate limit\b/i, + /insufficient balance/i, ]; /** diff --git a/src/shared/utils/providerQuotaVisibility.ts b/src/shared/utils/providerQuotaVisibility.ts index 3968005204..81edcdc450 100644 --- a/src/shared/utils/providerQuotaVisibility.ts +++ b/src/shared/utils/providerQuotaVisibility.ts @@ -1,13 +1,23 @@ import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; +import { isMoonshotOpenPlatformConnection } from "@omniroute/open-sse/services/usage/moonshotOpenPlatform.ts"; export interface ProviderQuotaVisibilityConnection { quotaVisible?: boolean; + provider?: string; + providerSpecificData?: unknown; } export function isProviderQuotaVisible(connection: ProviderQuotaVisibilityConnection): boolean { return connection.quotaVisible !== false; } -export function supportsProviderQuota(providerId: string): boolean { - return USAGE_SUPPORTED_PROVIDERS.includes(providerId); +export function supportsProviderQuota( + providerId: string, + connection?: { provider?: string; providerSpecificData?: unknown }, +): boolean { + if (USAGE_SUPPORTED_PROVIDERS.includes(providerId)) return true; + return isMoonshotOpenPlatformConnection({ + provider: providerId, + providerSpecificData: connection?.providerSpecificData, + }); } diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index fa6f0ef055..c93e563ab7 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -22,16 +22,36 @@ import { isReservedProviderPrefix, reservedProviderPrefixMessage, } from "@/shared/constants/reservedProviderPrefixes"; - +import { + isValidIanaTimeZone, + isValidResetHour, +} from "@omniroute/open-sse/services/dailyQuotaReset.ts"; import { upstreamHeadersRecordSchema, modelCompatPerProtocolSchema, customHeadersSchema, } from "./misc.ts"; +import { isValidProviderIconUrl } from "@/shared/validation/iconUrl"; export { validateProviderSpecificData }; -import { isValidProviderIconUrl } from "@/shared/validation/iconUrl"; +const dailyQuotaResetTimezoneSchema = z + .string() + .trim() + .optional() + .or(z.literal("")) + .refine((value) => !value || isValidIanaTimeZone(value), { + message: "Unknown IANA timezone", + }); + +const dailyQuotaResetHourSchema = z + .number() + .int() + .optional() + .nullable() + .refine((value) => value == null || isValidResetHour(value), { + message: "Hour must be 0-23", + }); // ──── Provider Schemas ──── @@ -337,6 +357,8 @@ export const createProviderNodeSchema = z // isValidProviderIconUrl (2000 chars for http(s), 256 KiB for data:image). iconUrl: providerNodeIconUrlSchema, customHeaders: customHeadersSchema, + dailyQuotaResetTimezone: dailyQuotaResetTimezoneSchema, + dailyQuotaResetHour: dailyQuotaResetHourSchema, }) .superRefine((value, ctx) => { const nodeType = value.type || "openai-compatible"; @@ -419,6 +441,8 @@ export const updateProviderNodeSchema = z // clears a previously stored custom icon. iconUrl: providerNodeIconUrlSchema, customHeaders: customHeadersSchema, + dailyQuotaResetTimezone: dailyQuotaResetTimezoneSchema, + dailyQuotaResetHour: dailyQuotaResetHourSchema, }) .superRefine((value, ctx) => { // Reserved-prefix guard (tokenrouter bug) — same rationale as the guard in diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 6c426bcfce..7ad2131076 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -174,6 +174,7 @@ import { registerBailianCodingPlanQuotaFetcher } from "@omniroute/open-sse/servi import { registerQwenTokenPlanQuotaFetcher } from "@omniroute/open-sse/services/qwenTokenPlanQuotaFetcher.ts"; import { registerCrofUsageFetcher } from "@omniroute/open-sse/services/crofUsageFetcher.ts"; import { registerDeepseekQuotaFetcher } from "@omniroute/open-sse/services/deepseekQuotaFetcher.ts"; +import { registerMoonshotQuotaFetcher, registerMoonshotFetchersForNodes } from "@omniroute/open-sse/services/moonshotQuotaFetcher.ts"; import { registerOpenrouterQuotaFetcher } from "@omniroute/open-sse/services/openrouterQuotaFetcher.ts"; import { registerOpencodeQuotaFetcher } from "@omniroute/open-sse/services/opencodeQuotaFetcher.ts"; import { registerGrokWebQuotaFetcher } from "@omniroute/open-sse/services/grokQuotaFetcher.ts"; @@ -221,6 +222,21 @@ registerCrofUsageFetcher(); // Register DeepSeek balance quota fetcher. // Hooks into quotaPreflight + quotaMonitor so combos can switch accounts before balance is exhausted. registerDeepseekQuotaFetcher(); +registerMoonshotQuotaFetcher(); +void import("@/lib/db/providers") + .then(({ getProviderNodes }) => getProviderNodes()) + .then((nodes) => { + registerMoonshotFetchersForNodes( + (Array.isArray(nodes) ? nodes : []).map((node) => ({ + id: typeof node.id === "string" ? node.id : null, + prefix: typeof node.prefix === "string" ? node.prefix : null, + baseUrl: typeof node.baseUrl === "string" ? node.baseUrl : null, + })), + ); + }) + .catch((error) => { + console.warn("[STARTUP] Moonshot custom-node fetcher scan skipped:", error); + }); registerOpenrouterQuotaFetcher(); // Register OpenCode quota fetcher (opencode-go / opencode / opencode-zen). diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 697c8a036f..b121876376 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -2467,6 +2467,26 @@ export function isAgentrouterConnectionQuotaScope( ); } +async function resolveDailyResetForProvider( + provider: string | null, +): Promise<{ timezone?: unknown; hour?: unknown } | null> { + if (!provider) return null; + try { + const nodes = await getCachedProviderNodes(); + const node = nodes.find((candidate) => { + if (!candidate) return false; + return candidate.id === provider || candidate.prefix === provider; + }); + if (!node) return null; + return { + timezone: node.dailyQuotaResetTimezone, + hour: node.dailyQuotaResetHour, + }; + } catch { + return null; + } +} + /** * #10880 — cools down every connection sharing the failing connection's last * known egress IP. Best-effort and side-effect-safe by design: @@ -2683,7 +2703,10 @@ export async function markAccountUnavailable( model, provider, options.headers ?? null, - effectiveProviderProfile + effectiveProviderProfile, + null, + null, + await resolveDailyResetForProvider(provider), ); // T-PROBE: probe-origin failures (model test-all) must never remove the diff --git a/stryker.conf.json b/stryker.conf.json index 58dd1f4d4f..96f421f624 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -58,6 +58,7 @@ "tests/unit/account-fallback-retry-after-json.test.ts", "tests/unit/account-fallback-route-restriction-403.test.ts", "tests/unit/account-fallback-service.test.ts", + "tests/unit/moonshot-quota-writeback.test.ts", "tests/unit/accountfallback-ratelimit-400-4976.test.ts", "tests/unit/adaptive-admission-route-matrix.test.ts", "tests/unit/adaptive-admission-runtime.test.ts", diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index d28075545a..d2693a5acf 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -2005,3 +2005,51 @@ test("#10460 acceptance: unambiguous model-unsupported 400 makes exactly ONE ups ); } }); + +const MOONSHOT_COMPAT = "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf"; +const MOONSHOT_TPD = + "[429]: Your account org-x / proj-y request reached organization TPD rate limit, current: 1537190, limit: 1500000"; +const MOONSHOT_BROKE = + "[429]: Your account org-x is suspended due to insufficient balance, please recharge your account or check your plan and billing details"; + +test("checkFallbackError: compatible Moonshot insufficient balance is creditsExhausted", () => { + const result = checkFallbackError(429, MOONSHOT_BROKE, 0, null, MOONSHOT_COMPAT); + assert.equal(result.creditsExhausted, true); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); +}); + +test("checkFallbackError: compatible node empty wallet without billing-suspend phrasing is creditsExhausted", () => { + const result = checkFallbackError( + 429, + "You have insufficient balance, please recharge your account", + 0, + null, + MOONSHOT_COMPAT, + ); + assert.equal(result.creditsExhausted, true); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); +}); + +test("isDailyQuotaExhausted detects organization TPD rate limit", () => { + const { isDailyQuotaExhausted } = accountFallback; + assert.equal(isDailyQuotaExhausted(MOONSHOT_TPD), true); + assert.equal(isDailyQuotaExhausted("The engine is currently overloaded"), false); +}); + +test("checkFallbackError: TPD with node clock uses that instant, not host midnight", () => { + const now = Date.parse("2026-09-02T07:30:00Z"); + const result = checkFallbackError(429, MOONSHOT_TPD, 0, null, MOONSHOT_COMPAT, null, null, null, null, { + timezone: "Asia/Shanghai", + hour: 0, + nowMs: now, + }); + assert.equal(result.dailyQuotaExhausted, true); + assert.equal(result.cooldownMs, Date.parse("2026-09-02T16:00:00Z") - now); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); +}); + +test("checkFallbackError: TPD without clock or header is NOT host-midnight lock", () => { + const result = checkFallbackError(429, MOONSHOT_TPD, 0, null, MOONSHOT_COMPAT); + assert.notEqual(result.dailyQuotaExhausted, true); + assert.ok(result.cooldownMs < 2 * 60 * 60 * 1000); +}); diff --git a/tests/unit/api-key-self-service.test.ts b/tests/unit/api-key-self-service.test.ts index 6b768f7950..242c3b22b3 100644 --- a/tests/unit/api-key-self-service.test.ts +++ b/tests/unit/api-key-self-service.test.ts @@ -435,3 +435,39 @@ test("self-service status normalizes Codex account quota for one explicit connec }, }); }); + +test("self-service fetches Moonshot custom-node quota via providerSpecificData host", async () => { + const metadata = { + id: "key-mnative", + name: "moonshot native", + scopes: [SELF_USAGE_SCOPE, SELF_ACCOUNT_QUOTA_SCOPE], + allowedConnections: ["conn-mnative"], + }; + const fetches: string[] = []; + const { deps } = makeDeps({ + getProviderConnectionById: async (connectionId: string) => ({ + id: connectionId, + provider: "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf", + isActive: true, + providerSpecificData: { baseUrl: "https://api.moonshot.cn/v1" }, + }), + fetchAndPersistProviderLimits: async (connectionId: string) => { + fetches.push(connectionId); + return { + connection: { id: connectionId, provider: "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf" }, + usage: { + plan: "Kimi 开放平台(国内)", + quotas: { + available: { remaining: 15, remainingPercentage: 100, unlimited: true, currency: "CNY" }, + }, + }, + cache: { quotas: null, plan: null, message: null, fetchedAt: "" }, + }; + }, + }); + + const status = await buildApiKeySelfServiceStatus(metadata, deps); + assert.deepEqual(fetches, ["conn-mnative"]); + assert.equal(status.accountQuotas[0].unavailable, undefined); + assert.equal(status.accountQuotas[0].plan, "Kimi 开放平台(国内)"); +}); diff --git a/tests/unit/classify429.test.ts b/tests/unit/classify429.test.ts index d75d7f648f..6d42875afe 100644 --- a/tests/unit/classify429.test.ts +++ b/tests/unit/classify429.test.ts @@ -443,3 +443,18 @@ test("classify429: retryDelay outside a RetryInfo detail is ignored", () => { }; assert.equal(classify429({ status: 429, body }), "quota_exhausted"); }); + +const MOONSHOT_TPD = + "Your account org-73b383ab6d45484eb2ef72161074495c / proj-30863601b47548bdbbabc42ff4c72eee request reached organization TPD rate limit, current: 1537190, limit: 1500000"; + +test("classify429: Moonshot organization TPD rate limit is quota_exhausted", () => { + assert.equal(classify429({ status: 429, body: MOONSHOT_TPD }), "quota_exhausted"); + assert.equal(looksLikeQuotaExhausted(MOONSHOT_TPD), true); +}); + +test("classify429: Moonshot engine overloaded stays rate_limit", () => { + assert.equal( + classify429({ status: 429, body: "The engine is currently overloaded, please try again later" }), + "rate_limit", + ); +}); diff --git a/tests/unit/daily-quota-reset.test.ts b/tests/unit/daily-quota-reset.test.ts new file mode 100644 index 0000000000..0d2c78cfaf --- /dev/null +++ b/tests/unit/daily-quota-reset.test.ts @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + isValidIanaTimeZone, + isValidResetHour, + nodeDailyResetConfigured, + nextDailyResetAtMs, + parseTpdLimitFromText, +} from "../../open-sse/services/dailyQuotaReset.ts"; + +test("IANA: Asia/Shanghai ok, garbage rejected", () => { + assert.equal(isValidIanaTimeZone("Asia/Shanghai"), true); + assert.equal(isValidIanaTimeZone("America/New_York"), true); + assert.equal(isValidIanaTimeZone("Not/AZone"), false); + assert.equal(isValidIanaTimeZone(""), false); +}); + +test("isValidResetHour accepts 0-23 integers only", () => { + assert.equal(isValidResetHour(0), true); + assert.equal(isValidResetHour(23), true); + assert.equal(isValidResetHour(24), false); + assert.equal(isValidResetHour(-1), false); + assert.equal(isValidResetHour(1.5), false); + assert.equal(isValidResetHour(null), false); +}); + +test("nodeDailyResetConfigured requires both fields", () => { + assert.equal(nodeDailyResetConfigured("Asia/Shanghai", 0), true); + assert.equal(nodeDailyResetConfigured("Asia/Shanghai", null), false); + assert.equal(nodeDailyResetConfigured(null, 0), false); + assert.equal(nodeDailyResetConfigured("Not/AZone", 0), false); + assert.equal(nodeDailyResetConfigured("Asia/Shanghai", 24), false); +}); + +test("nextDailyResetAtMs locks to next local hour:00", () => { + // 2026-09-02 15:30 in Asia/Shanghai = 2026-09-02 07:30 UTC + const now = Date.parse("2026-09-02T07:30:00Z"); + const next = nextDailyResetAtMs("Asia/Shanghai", 0, now); + // next calendar day 00:00 Shanghai = 2026-09-02 16:00 UTC + assert.equal(next, Date.parse("2026-09-02T16:00:00Z")); +}); + +test("nextDailyResetAtMs at exact reset instant returns the following cycle", () => { + const exactly = Date.parse("2026-09-02T16:00:00Z"); // 00:00 Shanghai + const next = nextDailyResetAtMs("Asia/Shanghai", 0, exactly); + assert.equal(next, Date.parse("2026-09-03T16:00:00Z")); +}); + +test("parseTpdLimitFromText reads limit: from live body", () => { + const body = + "request reached organization TPD rate limit, current: 1537190, limit: 1500000"; + assert.equal(parseTpdLimitFromText(body), 1_500_000); + assert.equal(parseTpdLimitFromText("no numbers"), null); +}); diff --git a/tests/unit/moonshot-open-platform.test.ts b/tests/unit/moonshot-open-platform.test.ts new file mode 100644 index 0000000000..c44e1d4859 --- /dev/null +++ b/tests/unit/moonshot-open-platform.test.ts @@ -0,0 +1,72 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + parseMoonshotOrigin, + moonshotBalanceUrl, + resolveMoonshotOrigin, + isMoonshotOpenPlatformConnection, +} from "../../open-sse/services/usage/moonshotOpenPlatform.ts"; + +const CN = "https://api.moonshot.cn/v1"; +const AI = "https://api.moonshot.ai/v1"; +const COMPAT = "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf"; + +test("parseMoonshotOrigin accepts cn and ai hosts only", () => { + assert.equal(parseMoonshotOrigin(CN), "https://api.moonshot.cn"); + assert.equal( + parseMoonshotOrigin("https://api.moonshot.cn/v1/chat/completions"), + "https://api.moonshot.cn", + ); + assert.equal(parseMoonshotOrigin(AI), "https://api.moonshot.ai"); + assert.equal(parseMoonshotOrigin("https://api.openai.com/v1"), null); + assert.equal(parseMoonshotOrigin("https://api.kimi.com/coding/v1"), null); + assert.equal(parseMoonshotOrigin(""), null); + assert.equal(parseMoonshotOrigin(null), null); +}); + +test("moonshotBalanceUrl stays on the connection origin", () => { + assert.equal( + moonshotBalanceUrl("https://api.moonshot.cn"), + "https://api.moonshot.cn/v1/users/me/balance", + ); + assert.equal( + moonshotBalanceUrl("https://api.moonshot.ai"), + "https://api.moonshot.ai/v1/users/me/balance", + ); +}); + +test("resolveMoonshotOrigin prefers psd.baseUrl over node", () => { + const origin = resolveMoonshotOrigin( + { + provider: COMPAT, + providerSpecificData: { baseUrl: CN }, + }, + "https://api.moonshot.ai/v1", + ); + assert.equal(origin, "https://api.moonshot.cn"); +}); + +test("resolveMoonshotOrigin uses node baseUrl when psd has none", () => { + const origin = resolveMoonshotOrigin({ provider: COMPAT, providerSpecificData: {} }, CN); + assert.equal(origin, "https://api.moonshot.cn"); +}); + +test("resolveMoonshotOrigin uses built-in moonshot/kimi registry host", () => { + assert.equal(resolveMoonshotOrigin({ provider: "moonshot" }), "https://api.moonshot.ai"); + assert.equal(resolveMoonshotOrigin({ provider: "kimi" }), "https://api.moonshot.ai"); +}); + +test("isMoonshotOpenPlatformConnection is true for mnative-shaped rows", () => { + assert.equal( + isMoonshotOpenPlatformConnection({ + provider: COMPAT, + providerSpecificData: { baseUrl: CN, prefix: "mnative" }, + }), + true, + ); + assert.equal( + isMoonshotOpenPlatformConnection({ provider: "deepseek", providerSpecificData: {} }), + false, + ); + assert.equal(isMoonshotOpenPlatformConnection({ provider: "moonshot" }), true); +}); diff --git a/tests/unit/moonshot-quota-fetcher.test.ts b/tests/unit/moonshot-quota-fetcher.test.ts new file mode 100644 index 0000000000..1c315632b9 --- /dev/null +++ b/tests/unit/moonshot-quota-fetcher.test.ts @@ -0,0 +1,158 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + fetchMoonshotQuota, + invalidateMoonshotQuotaCache, + registerMoonshotQuotaFetcher, + getMoonshotOpenPlatformUsage, +} from "../../open-sse/services/moonshotQuotaFetcher.ts"; +import { getUsageForProvider } from "../../open-sse/services/usage.ts"; +import { getQuotaFetcher } from "../../open-sse/services/quotaPreflight.ts"; + +const originalFetch = globalThis.fetch; +const CN = "https://api.moonshot.cn/v1"; +const COMPAT = "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf"; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +test("fetchMoonshotQuota parses available_balance > 0 as not exhausted", async () => { + const connectionId = `ms-pos-${Date.now()}`; + globalThis.fetch = async (url) => { + assert.equal(String(url), "https://api.moonshot.cn/v1/users/me/balance"); + return jsonResponse({ + code: 0, + data: { available_balance: 2.5, voucher_balance: 0, cash_balance: 2.5 }, + status: true, + }); + }; + const q = await fetchMoonshotQuota(connectionId, { + apiKey: "sk-test", + providerSpecificData: { baseUrl: CN }, + }); + assert.equal(q?.limitReached, false); + assert.equal(q?.percentUsed, 0); + invalidateMoonshotQuotaCache(connectionId); +}); + +test("fetchMoonshotQuota treats available_balance 0 as exhausted", async () => { + const connectionId = `ms-zero-${Date.now()}`; + globalThis.fetch = async () => + jsonResponse({ + code: 0, + data: { available_balance: 0, voucher_balance: 0, cash_balance: 0 }, + status: true, + }); + const q = await fetchMoonshotQuota(connectionId, { + apiKey: "sk-test", + providerSpecificData: { baseUrl: CN }, + }); + assert.equal(q?.limitReached, true); + invalidateMoonshotQuotaCache(connectionId); +}); + +test("fetchMoonshotQuota returns null on 401", async () => { + const connectionId = `ms-401-${Date.now()}`; + globalThis.fetch = async () => new Response(null, { status: 401 }); + const q = await fetchMoonshotQuota(connectionId, { + apiKey: "sk-test", + providerSpecificData: { baseUrl: CN }, + }); + assert.equal(q, null); +}); + +test("getUsageForProvider on custom uuid hits Moonshot path", async () => { + const connectionId = `ms-uuid-${Date.now()}`; + globalThis.fetch = async () => + jsonResponse({ + code: 0, + data: { available_balance: 15, voucher_balance: 15, cash_balance: 0 }, + status: true, + }); + const usage = await getUsageForProvider({ + id: connectionId, + provider: COMPAT, + apiKey: "sk-test", + providerSpecificData: { baseUrl: CN }, + }); + assert.equal(typeof usage === "object" && usage && "message" in usage, false); + assert.equal((usage as { plan?: string }).plan, "Kimi 开放平台(国内)"); + invalidateMoonshotQuotaCache(connectionId); +}); + +test("getMoonshotOpenPlatformUsage uses Open Platform plan label for .ai host", async () => { + const connectionId = `ms-ai-${Date.now()}`; + globalThis.fetch = async (url) => { + assert.equal(String(url), "https://api.moonshot.ai/v1/users/me/balance"); + return jsonResponse({ + code: 0, + data: { available_balance: 1, voucher_balance: 0, cash_balance: 1 }, + status: true, + }); + }; + const usage = await getMoonshotOpenPlatformUsage({ + id: connectionId, + provider: "moonshot", + apiKey: "sk-test", + providerSpecificData: { baseUrl: "https://api.moonshot.ai/v1" }, + }); + assert.equal(usage.plan, "Kimi Open Platform"); + invalidateMoonshotQuotaCache(connectionId); +}); + +test("domestic Moonshot balance is labeled CNY, international USD", async () => { + const cnId = `ms-cny-${Date.now()}`; + globalThis.fetch = async () => + jsonResponse({ + code: 0, + data: { available_balance: 15, voucher_balance: 15, cash_balance: 0 }, + status: true, + }); + const cn = await getMoonshotOpenPlatformUsage({ + id: cnId, + provider: COMPAT, + apiKey: "sk-test", + providerSpecificData: { baseUrl: CN }, + }); + assert.equal(cn.quotas?.available?.currency, "CNY"); + invalidateMoonshotQuotaCache(cnId); + + const aiId = `ms-usd-${Date.now()}`; + const ai = await getMoonshotOpenPlatformUsage({ + id: aiId, + provider: "moonshot", + apiKey: "sk-test", + providerSpecificData: { baseUrl: "https://api.moonshot.ai/v1" }, + }); + assert.equal(ai.quotas?.available?.currency, "USD"); + invalidateMoonshotQuotaCache(aiId); +}); + +test("registerMoonshotQuotaFetcher wires moonshot and kimi ids", () => { + registerMoonshotQuotaFetcher(); + assert.equal(typeof getQuotaFetcher("moonshot"), "function"); + assert.equal(typeof getQuotaFetcher("kimi"), "function"); +}); + +test("registerMoonshotFetchersForNodes registers custom node id and prefix", async () => { + const { registerMoonshotFetchersForNodes } = await import( + "../../open-sse/services/moonshotQuotaFetcher.ts" + ); + const uuid = "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf"; + registerMoonshotFetchersForNodes([ + { id: uuid, prefix: "mnative", baseUrl: CN }, + { id: "other", prefix: "oc-prod", baseUrl: "https://api.openai.com/v1" }, + ]); + assert.equal(typeof getQuotaFetcher(uuid), "function"); + assert.equal(typeof getQuotaFetcher("mnative"), "function"); + assert.equal(getQuotaFetcher("oc-prod"), undefined); +}); diff --git a/tests/unit/moonshot-quota-writeback.test.ts b/tests/unit/moonshot-quota-writeback.test.ts new file mode 100644 index 0000000000..14c8e3168b --- /dev/null +++ b/tests/unit/moonshot-quota-writeback.test.ts @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { classify429 } from "../../src/shared/utils/classify429.ts"; +import { checkFallbackError } from "../../open-sse/services/accountFallback.ts"; +import { registerMoonshotFetchersForNodes } from "../../open-sse/services/moonshotQuotaFetcher.ts"; +import { getQuotaFetcher } from "../../open-sse/services/quotaPreflight.ts"; + +const MOONSHOT_TPD = + "Your account org-73b383ab6d45484eb2ef72161074495c / proj-30863601b47548bdbbabc42ff4c72eee request reached organization TPD rate limit, current: 1537190, limit: 1500000"; +const MOONSHOT_BROKE = "insufficient balance"; +const COMPAT = "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf"; + +test("classify429 maps Moonshot TPD to quota_exhausted so combo persist stays on", () => { + assert.equal(classify429({ status: 429, body: MOONSHOT_TPD }), "quota_exhausted"); +}); + +test("checkFallbackError TPD with node clock returns future cooldown, not host midnight", () => { + const now = Date.parse("2026-09-02T07:30:00Z"); + const result = checkFallbackError( + 429, + MOONSHOT_TPD, + 0, + "kimi-k2.5", + COMPAT, + null, + null, + null, + null, + { timezone: "Asia/Shanghai", hour: 0, nowMs: now }, + ); + assert.equal(result.shouldFallback, true); + assert.equal(result.dailyQuotaExhausted, true); + assert.ok(result.cooldownMs > 8 * 3600_000); + const until = now + result.cooldownMs; + assert.ok(Math.abs(until - Date.parse("2026-09-02T16:00:00Z")) < 60_000); +}); + +test("checkFallbackError insufficient balance on compatible node is creditsExhausted", () => { + const result = checkFallbackError(429, MOONSHOT_BROKE, 0, "kimi-k2.5", COMPAT); + assert.equal(result.creditsExhausted, true); + assert.equal(result.shouldFallback, true); +}); + +test("registerMoonshotFetchersForNodes wires uuid and prefix after startup scan", () => { + registerMoonshotFetchersForNodes([ + { id: COMPAT, prefix: "mnative", baseUrl: "https://api.moonshot.cn/v1" }, + ]); + assert.equal(typeof getQuotaFetcher(COMPAT), "function"); + assert.equal(typeof getQuotaFetcher("mnative"), "function"); +}); diff --git a/tests/unit/provider-node-daily-reset-schema.test.ts b/tests/unit/provider-node-daily-reset-schema.test.ts new file mode 100644 index 0000000000..f20a918b68 --- /dev/null +++ b/tests/unit/provider-node-daily-reset-schema.test.ts @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { createProviderNodeSchema, updateProviderNodeSchema } = + await import("../../src/shared/validation/schemas/provider.ts"); + +const BASE = { + name: "Moonshot native", + prefix: "mnative", + baseUrl: "https://api.moonshot.cn/v1", +}; + +test("updateProviderNodeSchema accepts IANA timezone + hour 0", () => { + const result = updateProviderNodeSchema.safeParse({ + ...BASE, + dailyQuotaResetTimezone: "Asia/Shanghai", + dailyQuotaResetHour: 0, + }); + assert.equal(result.success, true); +}); + +test("updateProviderNodeSchema rejects unknown timezone", () => { + const result = updateProviderNodeSchema.safeParse({ + ...BASE, + dailyQuotaResetTimezone: "Shanghai", + dailyQuotaResetHour: 0, + }); + assert.equal(result.success, false); +}); + +test("updateProviderNodeSchema rejects hour 24", () => { + const result = updateProviderNodeSchema.safeParse({ + ...BASE, + dailyQuotaResetTimezone: "Asia/Shanghai", + dailyQuotaResetHour: 24, + }); + assert.equal(result.success, false); +}); + +test("updateProviderNodeSchema accepts omitted reset clock", () => { + const result = updateProviderNodeSchema.safeParse(BASE); + assert.equal(result.success, true); +}); + +test("createProviderNodeSchema accepts IANA timezone + hour", () => { + const result = createProviderNodeSchema.safeParse({ + ...BASE, + apiType: "chat", + type: "openai-compatible", + dailyQuotaResetTimezone: "UTC", + dailyQuotaResetHour: 0, + }); + assert.equal(result.success, true); +}); diff --git a/tests/unit/provider-quota-visibility.test.ts b/tests/unit/provider-quota-visibility.test.ts index 665687feed..a8551e3ccb 100644 --- a/tests/unit/provider-quota-visibility.test.ts +++ b/tests/unit/provider-quota-visibility.test.ts @@ -16,3 +16,15 @@ test("quota visibility controls are limited to providers with quota support", () assert.equal(supportsProviderQuota("codex"), true); assert.equal(supportsProviderQuota("openai"), false); }); + +test("supportsProviderQuota is true for moonshot-native shaped connection", () => { + assert.equal( + supportsProviderQuota("openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf", { + providerSpecificData: { baseUrl: "https://api.moonshot.cn/v1" }, + }), + true, + ); + assert.equal(supportsProviderQuota("moonshot"), true); + assert.equal(supportsProviderQuota("kimi"), true); + assert.equal(supportsProviderQuota("openai"), false); +}); diff --git a/tests/unit/qoder-usage-quota.test.ts b/tests/unit/qoder-usage-quota.test.ts index 14666abc11..8e69a8991e 100644 --- a/tests/unit/qoder-usage-quota.test.ts +++ b/tests/unit/qoder-usage-quota.test.ts @@ -148,6 +148,15 @@ test("a qoder PAT (apikey) connection is picked up by the provider-limits sync", isSupportedUsageConnection({ id: "c2", provider: "some-random-provider", authType: "apikey" }), false ); + assert.equal( + isSupportedUsageConnection({ + id: "c3", + provider: "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf", + authType: "apikey", + providerSpecificData: { baseUrl: "https://api.moonshot.cn/v1" }, + }), + true, + ); }); // Guards the shared exchange contract the usage path relies on. From d9526cefeaea1f4836a947f4a56944fdaf4e8370 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 3 Sep 2026 12:49:53 -0300 Subject: [PATCH 34/47] chore(quality): rebaseline file-size caps the HouMinXi batch grew past (#12619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebaseline medido no tip com os 9 PRs da leva mergeados. Desfaz o vermelho de file-size que os PRs empilhados deixaram; não toca codex.ts nem stream.ts, que já violavam antes da leva. --- changelog.d/maintenance/houminxi-batch-filesize.md | 1 + config/quality/file-size-baseline.json | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 changelog.d/maintenance/houminxi-batch-filesize.md diff --git a/changelog.d/maintenance/houminxi-batch-filesize.md b/changelog.d/maintenance/houminxi-batch-filesize.md new file mode 100644 index 0000000000..0d3426ed51 --- /dev/null +++ b/changelog.d/maintenance/houminxi-batch-filesize.md @@ -0,0 +1 @@ +- **chore(quality):** rebaseline the file-size caps the HouMinXi batch grew past when its PRs stacked (`providers/page.tsx`, `chatCore.ts`, `accountFallback.ts`) — each PR measured correctly in isolation, none saw the stacking diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index cfa43566b4..86ea2f3a86 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -415,12 +415,12 @@ "open-sse/executors/codex.ts": 1499, "open-sse/executors/cursor.ts": 1759, "open-sse/executors/muse-spark-web.ts": 1405, - "open-sse/handlers/chatCore.ts": 5981, + "open-sse/handlers/chatCore.ts": 5984, "open-sse/handlers/imageGeneration.ts": 3259, "open-sse/handlers/search.ts": 1789, "open-sse/mcp-server/schemas/tools.ts": 1621, "open-sse/mcp-server/server.ts": 1572, - "open-sse/services/accountFallback.ts": 2461, + "open-sse/services/accountFallback.ts": 2467, "open-sse/services/adobeFireflyBrowserLogin.ts": 1401, "open-sse/services/combo.ts": 4023, "open-sse/translator/response/openai-responses.ts": 1466, @@ -435,7 +435,7 @@ "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1319, "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2491, "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1631, - "src/app/(dashboard)/dashboard/providers/page.tsx": 2007, + "src/app/(dashboard)/dashboard/providers/page.tsx": 2025, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201, "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1475, "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1271, @@ -633,5 +633,6 @@ "open-sse/executors/chatgpt-web.ts": "3241", "_rebaseline_2026_08_30_11771_vercel_gateway_passthrough": "PR #11771 adds passthroughModels: true (1 line) to the Vercel AI Gateway registry entry — no split available, single-line provider-config addition.", "_relax_velocity_2026_08_30": "127 frozen line caps and cap/testCap raised by 20% (velocity phase; see quality-baseline.json _policy).", - "_rebaseline_2026_09_02_12325_merge_v3851": "Merge of release/v3.8.51 into #12325. Both sides grew chatCore.ts at the same chokepoint: #12239 took it 5946->5976 upstream, and this PR adds its +9 non-Codex 429 branch on top. check-file-size.mjs counts split(\"\\\\n\").length (trailing-newline empty element), so the merged file is 5981. The cap is the merged LOC, not either side alone; no other entry moves." + "_rebaseline_2026_09_02_12325_merge_v3851": "Merge of release/v3.8.51 into #12325. Both sides grew chatCore.ts at the same chokepoint: #12239 took it 5946->5976 upstream, and this PR adds its +9 non-Codex 429 branch on top. check-file-size.mjs counts split(\"\\\\n\").length (trailing-newline empty element), so the merged file is 5981. The cap is the merged LOC, not either side alone; no other entry moves.", + "_rebaseline_2026_09_03_houminxi_batch_stacked": "Crescimento medido DEPOIS que os 9 PRs da leva HouMinXi entraram, quando cada um empilhou sobre o rebaseline do anterior: providers/page.tsx 2007->2025 (+18 = feedback de erro por linha do import CSV do #12504 somado a busca por nome/baseUrl do #12495, ambos no mesmo painel de conexoes); chatCore.ts 5981->5984 (+3 = o #12325 invalida o cache generico de quota no 429 upstream, ao lado do ramo Codex ja existente); accountFallback.ts 2461->2467 (+6 = o #12566 empilha a carve-out de familia Antigravity sobre o rebaseline 2422->2461 que o #12590 registrou para o carve-out credits_exhausted da Moonshot; os dois tocam checkFallbackError). Cada PR mediu certo isoladamente, mas nenhum enxergava o empilhamento. Fiacao em chokepoints existentes. NAO cobre codex.ts nem stream.ts, que ja violavam no tip antes desta leva (drift da base)." } From d353870342590c270641bae07c937ec3c5e7614a Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Thu, 3 Sep 2026 18:00:08 +0200 Subject: [PATCH 35/47] fix(resourcePressure): log numeric detail on every rejection, recover faster (#12293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem. O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. Obrigado, @hartmark. --- open-sse/utils/resourcePressure.ts | 61 +++++++++++++++++++++--- open-sse/utils/resourcePressurePolicy.ts | 26 ++++++++-- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/open-sse/utils/resourcePressure.ts b/open-sse/utils/resourcePressure.ts index acef067a1e..f3ba7f77d9 100644 --- a/open-sse/utils/resourcePressure.ts +++ b/open-sse/utils/resourcePressure.ts @@ -67,9 +67,47 @@ function requireDuration(name: string, value: number): number { return value; } -function buildCriticalGuard(reason: PressureReason): ResourcePressureGuardResult { +/** + * Human-readable key=value detail appended to the rejection log line. Every + * rejection (immediate heap trip AND cached-critical-state reuse) goes + * through here, so this is the one place that needs the actual numbers — + * the bare reason code alone ("psi_some") gives an operator nothing to act + * on when deciding whether the guard is mistuned vs. genuinely saturated. + */ +function formatPressureDetail(detail: Record): string { + return Object.entries(detail) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${value ?? "null"}`) + .join(" "); +} + +/** Builds buildCriticalGuard's detail object for the cached-critical-state + * reuse path in check() -- pulled out of check() itself so that function's + * own cyclomatic complexity stays under the ratchet, not because this needs + * to be reused anywhere else. */ +function describeCachedPressure(params: { + signals: ResourceSignals | null; + recoveryStreak: number; + cacheAgeMs: number; +}): Record { + const cgroup = params.signals?.cgroup; + return { + psiSomeAvg10: params.signals?.psi?.someAvg10 ?? null, + psiFullAvg10: params.signals?.psi?.fullAvg10 ?? null, + cgroupCurrentMb: cgroup?.currentBytes ? Math.round(cgroup.currentBytes / MB) : null, + cgroupMaxMb: cgroup?.maxBytes ? Math.round(cgroup.maxBytes / MB) : null, + recoveryStreak: params.recoveryStreak, + sampleAgeMs: params.cacheAgeMs, + }; +} + +function buildCriticalGuard( + reason: PressureReason, + detail: Record = {} +): ResourcePressureGuardResult { + const detailText = formatPressureDetail(detail); console.warn( - `[resourcePressure] critical pressure guard tripped (reason=${reason}); returning 503` + `[resourcePressure] critical pressure guard tripped (reason=${reason}${detailText ? " " + detailText : ""}); returning 503` ); return { success: false, @@ -97,7 +135,10 @@ function immediateHeapGuard( if (thresholdMb == null) return null; const guard = checkHeapPressureGuard(heapUsedMb, thresholdMb); if (!guard) return null; - return buildCriticalGuard("v8_heap_absolute"); + return buildCriticalGuard("v8_heap_absolute", { + heapUsedMb: Math.round(heapUsedMb), + thresholdMb: Math.round(thresholdMb), + }); } export function createResourcePressureRuntime( @@ -192,9 +233,17 @@ export function createResourcePressureRuntime( return immediate; } const cacheAge = lastSignals ? Math.max(0, now - lastRefreshAtMs) : Number.POSITIVE_INFINITY; - return cacheAge <= maxStaleMs && state.severity === "critical" - ? buildCriticalGuard(state.reason) - : null; + if (cacheAge > maxStaleMs || state.severity !== "critical") { + return null; + } + return buildCriticalGuard( + state.reason, + describeCachedPressure({ + signals: lastSignals, + recoveryStreak: state.recoveryStreak, + cacheAgeMs: cacheAge, + }) + ); }, getObservation: () => ({ signals: lastSignals, state }), whenRefreshSettled: async () => { diff --git a/open-sse/utils/resourcePressurePolicy.ts b/open-sse/utils/resourcePressurePolicy.ts index 447a084b61..3887bf9bbc 100644 --- a/open-sse/utils/resourcePressurePolicy.ts +++ b/open-sse/utils/resourcePressurePolicy.ts @@ -74,12 +74,30 @@ export const DEFAULT_RESOURCE_PRESSURE_THRESHOLDS: ResourcePressureThresholds = highRatio: 0.85, criticalRatio: 0.92, recoveryRatio: 0.75, - highPsiAvg10: 20, - criticalPsiAvg10: 40, - recoveryPsiAvg10: 10, + // Bumped 50% (20/40/10 -> 30/60/15): /proc/pressure/memory reflects + // HOST-wide PSI, not this process's own cgroup pressure (confirmed by + // comparing /proc/pressure/memory against /sys/fs/cgroup/memory.pressure + // from inside a running container -- the two differ). On a shared host + // running many unrelated workloads, host-wide memory contention from + // OTHER processes was tripping this guard even while OmniRoute's own + // usage stayed trivial. The ratio-based thresholds above stay untouched + // -- they're this process's own real OOM safety margin and unaffected by + // noisy neighbors. + highPsiAvg10: 30, + criticalPsiAvg10: 60, + recoveryPsiAvg10: 15, sustainedSamplesHigh: 2, sustainedSamplesCritical: 2, - sustainedSamplesRecovery: 3, + // PSI's own avg10 is a kernel-computed 10s rolling average, so it already + // lags real recovery by design -- requiring 3 consecutive samples *on top* + // of that (at the ~1s default sample cadence) stacked another ~2-3s of + // guard-still-shedding time after the process was actually fine again. + // isRecovered() already requires every tracked ratio/PSI value to clear + // the separate, more conservative recoveryRatio/recoveryPsiAvg10 + // thresholds (not just dip under the critical ones), so a single clean + // sample is real signal, not noise -- the streak requirement was adding + // redundant delay on top of an already-conservative bar. + sustainedSamplesRecovery: 1, heapAbsoluteThresholdMb: null, }; From c091534ffcde1279767060dd5f9bb6735a99d65a Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Thu, 3 Sep 2026 18:00:27 +0200 Subject: [PATCH 36/47] fix(providers): stop an unrelated-provider tiktoken bundling failure from crashing /api/providers (#12355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem. O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. Obrigado, @hartmark. --- src/app/(dashboard)/dashboard/combos/page.tsx | 16 +++++++++----- src/app/api/providers/route.ts | 21 ++++++++++++++++--- .../providers/validation/chatgptWebCodex.ts | 13 +++++++++++- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 978a5f1321..0105dd8723 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -761,15 +761,21 @@ function CombosPageContent() { const [proxyConfig, setProxyConfig] = useState(null); const { comboProxyAssignedIds, fetchComboProxyAssignments } = useComboProxyAssignments(); const [providerNodes, setProviderNodes] = useState([]); - const [showUsageGuide, setShowUsageGuide] = useState(() => { - // Lazy initializer instead of a mount effect (react-hooks/set-state-in-effect). + // SSR has no localStorage, so a lazy initializer reading it here returns a + // different value server-side (always "not dismissed") than the client's + // real stored value -- exactly the kind of source React's hydration + // mismatch check is built to catch, and in dev mode a mismatch forces a + // full client-only re-render of this tree, discarding whatever the fetch + // effects below had already populated. Start with the SSR-safe default on + // both passes and correct it client-only, after hydration, in an effect. + const [showUsageGuide, setShowUsageGuide] = useState(true); + useEffect(() => { try { - return globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1"; + setShowUsageGuide(globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1"); } catch { // Ignore storage access errors (privacy mode / restricted environments) - return true; } - }); + }, []); const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState(""); const [creatingKimiPreset, setCreatingKimiPreset] = useState(false); const [comboDragIndex, setComboDragIndex] = useState(null); diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index bb5d1ded72..409f7445ab 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -47,7 +47,15 @@ import { fetchModelSyncInternal, getModelSyncInternalBaseUrl, } from "@/shared/services/modelSyncScheduler"; -import { finalizeValidatedChatGptWebCodexSecrets } from "@omniroute/open-sse/services/chatgptWebCodexAdmin.ts"; +// Dynamically imported below, inside the one `provider === "chatgpt-web-codex"` +// branch that needs it: this module's transitive chain pulls in tiktoken's +// WASM tokenizer, which Turbopack dev mode fails to resolve for this graph +// even with `tiktoken` listed in serverExternalPackages (the standalone +// Node require works fine; only Turbopack's bundling of this import path +// doesn't). A static top-level import evaluates that whole chain on EVERY +// /api/providers request regardless of provider, turning an unrelated- +// provider bug into a route-wide 500. Loading it lazily, only when actually +// needed, avoids paying that cost (and that risk) on the common path. import { isAutoFetchModelsEnabled } from "@/lib/providerModels/modelDiscovery"; import { testSingleConnection } from "./[id]/test/route"; import { rejectRetiredCommonChatGptWebProvider } from "@/lib/providers/chatgptWebRetirementResponse"; @@ -204,6 +212,8 @@ export async function POST(request: Request) { ? providerSpecificData.validationId : ""; try { + const { finalizeValidatedChatGptWebCodexSecrets } = + await import("@omniroute/open-sse/services/chatgptWebCodexAdmin.ts"); const finalized = finalizeValidatedChatGptWebCodexSecrets(apiKey || "", validationId); persistedApiKey = finalized.encodedCredential; providerSpecificData = { ...(providerSpecificData || {}) }; @@ -324,11 +334,16 @@ export async function POST(request: Request) { }) .then((syncRes) => { if (!syncRes.ok) { - console.log(`[providers] Auto-sync failed for ${newConnection.id}: ${syncRes.status}`); + console.log( + `[providers] Auto-sync failed for ${newConnection.id}: ${syncRes.status}` + ); } }) .catch((err) => { - console.log(`[providers] Auto-sync error for ${newConnection.id}:`, err?.message || err); + console.log( + `[providers] Auto-sync error for ${newConnection.id}:`, + err?.message || err + ); }); } catch (syncSetupError) { // Defensive: if URL parsing or header construction itself throws, do diff --git a/src/lib/providers/validation/chatgptWebCodex.ts b/src/lib/providers/validation/chatgptWebCodex.ts index 3dd6eda748..d95c1475f3 100644 --- a/src/lib/providers/validation/chatgptWebCodex.ts +++ b/src/lib/providers/validation/chatgptWebCodex.ts @@ -4,7 +4,6 @@ import { rmSync } from "node:fs"; import { CHATGPT_WEB_CODEX_CONNECTOR_NAME } from "@/shared/constants/chatgptWebCodex"; import { inspectBrowserLoginCapabilities } from "@omniroute/open-sse/vendor/codex-chatgpt-web/browser-login.ts"; import { decodeChatGptWebCodexSecrets } from "@omniroute/open-sse/executors/chatgpt-web-codex/credentials.ts"; -import { detectChromeExecutable } from "@omniroute/open-sse/executors/chatgpt-web-codex.ts"; import { connectionRuntimePaths, ensureConnectionStorageState, @@ -12,6 +11,16 @@ import { } from "@omniroute/open-sse/executors/chatgpt-web-codex/storageState.ts"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +// detectChromeExecutable (executors/chatgpt-web-codex.ts) is imported +// dynamically below, not statically here: this module is re-exported through +// the shared `@/lib/providers/validation` barrel that every provider +// validator's callers pull in, and executors/chatgpt-web-codex.ts's own +// import chain (its vendor browser adapter -> token-estimate.ts -> tiktoken's +// WASM tokenizer) fails to bundle under Turbopack dev mode even with +// `tiktoken` server-externalized -- turning validation of an unrelated +// provider into a route-wide crash for anyone who merely imports the barrel. +// A static import here evaluates that whole chain unconditionally. + export async function validateChatGptWebCodexProvider({ apiKey, providerSpecificData = {}, @@ -54,6 +63,8 @@ export async function validateChatGptWebCodexProvider({ }; } const cdpEndpoint = process.env.CHATGPT_WEB_CODEX_CDP_URL?.trim(); + const { detectChromeExecutable } = + await import("@omniroute/open-sse/executors/chatgpt-web-codex.ts"); const chromeExecutablePath = detectChromeExecutable( typeof providerSpecificData.chromeExecutablePath === "string" ? providerSpecificData.chromeExecutablePath From 7881e7eb72d4e35b6485a4389b1778c40b608f76 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Thu, 3 Sep 2026 18:00:44 +0200 Subject: [PATCH 37/47] fix(conversations): resolve turn content OmniRoute never sends back to the client (#12447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem. O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. Obrigado, @hartmark. --- open-sse/services/conversationTurnContent.ts | 101 ++++++++++++++++--- 1 file changed, 88 insertions(+), 13 deletions(-) diff --git a/open-sse/services/conversationTurnContent.ts b/open-sse/services/conversationTurnContent.ts index a95a39c939..bc5f3bdcef 100644 --- a/open-sse/services/conversationTurnContent.ts +++ b/open-sse/services/conversationTurnContent.ts @@ -23,6 +23,90 @@ export type TurnDisplayContent = { toolName: string | null; }; +type CanonicalTurnLike = { + role: "system" | "user" | "assistant" | "tool"; + text: string; + blockKind: "text" | "tool_use" | "tool_result"; + toolName: string | null; +}; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" ? (value as JsonRecord) : null; +} + +function turnsFromBody(body: unknown): CanonicalTurnLike[] { + const rec = asRecord(body); + return rec ? extractCanonicalTurns(rec) : []; +} + +/** + * extractCanonicalTurns's Chat Completions branch only reads a message's + * `content` -- a tool-calling assistant message carries its call in + * `tool_calls` instead with `content: null`, so it silently produces no turn + * at all and the matching conversation_turn_nodes row can never resolve. + * Deliberately scoped to this read-only display path instead of extending + * extractCanonicalTurns itself: that function also drives + * conversationTracker.ts's write-path identity/hashing, and this codebase's + * only caller of it there (chat.ts's resolveConversationId) always feeds the + * client-facing Responses-API body -- never Chat Completions + * `messages`/`tool_calls` -- so extending it there would be unreachable for + * real traffic here but still carries real write-path identity-hash risk for + * any other caller/format that function might ever serve. Mirrors + * extractCanonicalTurns's own Responses-shape function_call handling: one + * turn per call, role "tool" (matches how a Responses API function_call item, + * which also carries no `role`, canonicalizes -- not "assistant"), toolName + * from the call, text the raw arguments string untouched (already a JSON + * string in both APIs, so passing it through unmodified is what a + * byte-identical hash against the original Responses-shaped item needs). + */ +function extractChatCompletionsToolUseTurns(messages: unknown): CanonicalTurnLike[] { + if (!Array.isArray(messages)) return []; + const turns: CanonicalTurnLike[] = []; + for (const item of messages) { + const rec = asRecord(item) ?? {}; + if (rec.role !== "assistant" || !Array.isArray(rec.tool_calls)) continue; + for (const call of rec.tool_calls) { + const fn = asRecord(asRecord(call)?.function); + const args = fn?.arguments; + if (typeof args !== "string" || !args) continue; + turns.push({ + role: "tool", + text: args, + blockKind: "tool_use", + toolName: typeof fn?.name === "string" ? fn.name : null, + }); + } + } + return turns; +} + +function turnsFromClientResponse(clientResponse: unknown): CanonicalTurnLike[] { + const rec = asRecord(clientResponse); + if (!rec) return []; + const summary = asRecord(rec.summary); + const output = Array.isArray(rec.output) ? rec.output : summary?.output; + return Array.isArray(output) ? extractCanonicalTurns({ input: output }) : []; +} + +function turnsFromProviderRequest(body: unknown): CanonicalTurnLike[] { + const rec = asRecord(body); + return [...turnsFromBody(rec), ...extractChatCompletionsToolUseTurns(rec?.messages)]; +} + +function indexTurns(result: Map, turns: CanonicalTurnLike[]): void { + for (const turn of turns) { + const hash = hashTurnContent(turn); + if (result.has(hash)) continue; + result.set(hash, { + textPreview: turn.text, + blockKind: turn.blockKind, + toolName: turn.toolName, + }); + } +} + /** * Resolve display content for a batch of turn nodes, keyed by content_hash. * Content_hash is sha256(role+text) only — real traffic has plenty of @@ -64,19 +148,10 @@ export function resolveTurnDisplayContent( for (const relPath of artifactPathByCorrelationId.values()) { const { artifact, state } = readCallArtifact(relPath); if (state !== "ready") continue; - const clientRawRequest = artifact?.pipeline?.clientRawRequest as { body?: unknown } | undefined; - const body = clientRawRequest?.body; - if (!body || typeof body !== "object") continue; - - for (const turn of extractCanonicalTurns(body as Record)) { - const hash = hashTurnContent(turn); - if (result.has(hash)) continue; - result.set(hash, { - textPreview: turn.text, - blockKind: turn.blockKind, - toolName: turn.toolName, - }); - } + const pipeline = asRecord(artifact?.pipeline); + indexTurns(result, turnsFromBody(asRecord(pipeline?.clientRawRequest)?.body)); + indexTurns(result, turnsFromClientResponse(pipeline?.clientResponse)); + indexTurns(result, turnsFromProviderRequest(asRecord(pipeline?.providerRequest)?.body)); } return result; } From 4ec4ce410e7b6ef0776054222539f89fc0c9b2eb Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Thu, 3 Sep 2026 18:01:03 +0200 Subject: [PATCH 38/47] fix(sse): remap non-contiguous upstream tool_calls index to a gap-free output_index (#12445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem. O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. Obrigado, @hartmark. --- .../translator/response/openai-responses.ts | 6 +- .../openai-responses/toolCallLocalIndex.ts | 35 +++++ .../translator-resp-openai-responses.test.ts | 140 ++++++++++++++++++ 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 open-sse/translator/response/openai-responses/toolCallLocalIndex.ts diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 4d255d40be..a2244f01f5 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -23,12 +23,12 @@ import { import { createEventEmitter } from "./openai-responses/eventEmitter.ts"; import { buildResponsesToolCallItem } from "./responsesToolItem.ts"; import { resolveRequestToolIdentity } from "./openai-responses/requestToolIdentity.ts"; +import { resolveLocalToolCallIndex } from "./openai-responses/toolCallLocalIndex.ts"; import { synthesizeCompletedToolCalls, computeFinishReason, withAssistantRoleOnFirstDelta, } from "./openai-responses/synthesizeCompletedToolCalls.ts"; - // normalizeUpstreamFailure is re-exported for external importers (tests). export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts"; @@ -506,7 +506,7 @@ function toolCallOutputIndexBase(state) { function emitToolCall(state, emit, tc) { const tcIdx = tc.index ?? 0; - const outputIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(tcIdx); + const outputIndex = toolCallOutputIndexBase(state) + resolveLocalToolCallIndex(state, tcIdx); const newCallId = tc.id; const funcName = tc.function?.name; @@ -609,7 +609,7 @@ function emitToolCall(state, emit, tc) { function closeToolCall(state, emit, idx, recordAsCompleted = true) { const callId = state.funcCallIds[idx]; if (callId && !state.funcItemDone[idx]) { - const normalizedIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(idx); + const normalizedIndex = toolCallOutputIndexBase(state) + resolveLocalToolCallIndex(state, idx); const args = state.funcArgsBuf[idx] || "{}"; const toolName = state.funcNames[idx] || ""; // See emitToolCall()'s isCustomTool comment — must stay in sync (both compute the diff --git a/open-sse/translator/response/openai-responses/toolCallLocalIndex.ts b/open-sse/translator/response/openai-responses/toolCallLocalIndex.ts new file mode 100644 index 0000000000..645278e1fd --- /dev/null +++ b/open-sse/translator/response/openai-responses/toolCallLocalIndex.ts @@ -0,0 +1,35 @@ +/** + * Remap a turn's raw upstream tool_calls delta `index` onto a local, + * contiguous, 0-based sequence in first-seen order. + * + * Live incident (2026-09-02, minimax-m3:free via OpenRouter/GMICloud): the + * upstream's own `index` doesn't reliably start at 0 or stay contiguous per + * turn — this turn's two calls arrived with raw index 1 and 2 (never 0). + * Adding that raw index straight onto toolCallOutputIndexBase() left a GAP + * in the emitted output_index sequence (0 for the message, then 2 and 3 for + * the calls — index 1 never used). A client that reads response.completed's + * final `output[]` array by ARRAY POSITION and expects position to equal + * output_index (the Responses API's own contract) reads output[1] (this + * turn's first call, real output_index 2) while looking it up under + * output_index 1, misses it, then reads output[2] (the second call, real + * output_index 3) under output_index 2 — landing on the FIRST call's tracked + * slot with a different call_id, which a spec-following client correctly + * treats as "stream changed output item identity" and aborts. + */ + +export type ToolCallLocalIndexState = { + toolCallLocalIndex?: Record; + toolCallLocalIndexNext?: number; +}; + +export function resolveLocalToolCallIndex( + state: ToolCallLocalIndexState, + tcIdx: string | number +): number { + if (!state.toolCallLocalIndex) state.toolCallLocalIndex = {}; + if (state.toolCallLocalIndex[tcIdx] === undefined) { + state.toolCallLocalIndex[tcIdx] = state.toolCallLocalIndexNext ?? 0; + state.toolCallLocalIndexNext = state.toolCallLocalIndex[tcIdx] + 1; + } + return state.toolCallLocalIndex[tcIdx]; +} diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 2253c655ac..3a42eca624 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -994,3 +994,143 @@ test("OpenAI -> Responses: a text message and a following tool call in the same "completed output must include the tool call" ); }); + +// Live incident (2026-09-02): a free-tier streaming model, after a short text +// preamble, opened two tool calls whose upstream `tool_calls[].index` was 1 +// and 2 -- never 0. toolCallOutputIndexBase()+index therefore emitted +// output_index 0 (message), 2, 3 -- skipping 1 entirely. A spec-following +// Responses-API client reads response.completed's final `output[]` array by +// ARRAY POSITION and expects position === output_index (the API's own +// contract): output[1] (this turn's first call, real output_index 2) gets +// looked up under output_index 1 and missed, then output[2] (the second +// call, real output_index 3) gets looked up under output_index 2 and +// collides with the FIRST call's tracked slot -- two different call_ids on +// what the client thinks is one identity, which it correctly refuses to +// treat as anything but a broken stream. Reproduced verbatim (anonymized +// content, same index/id shape) against OpenClaw's own +// createResponsesOutputTracker before this fix; content and tool/model names +// below are placeholders, not the real incident's. +test("OpenAI -> Responses: tool-call output_index stays gap-free when the upstream's own index doesn't start at 0", () => { + const events = collectEvents([ + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { index: 0, delta: { content: "Status:", role: "assistant" }, finish_reason: null }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { index: 0, delta: { content: " all clear.", role: "assistant" }, finish_reason: null }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { + index: 0, + delta: { + content: null, + role: "assistant", + tool_calls: [ + { + index: 1, + id: "call_stub_1", + type: "function", + function: { name: "notify", arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { + index: 0, + delta: { + content: null, + role: "assistant", + tool_calls: [{ index: 1, function: { arguments: '{"a":1}' } }], + }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { + index: 0, + delta: { + content: null, + role: "assistant", + tool_calls: [ + { + index: 2, + id: "call_stub_2", + type: "function", + function: { name: "notify", arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { + index: 0, + delta: { + content: null, + role: "assistant", + tool_calls: [{ index: 2, function: { arguments: '{"a":2}' } }], + }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { index: 0, delta: { content: "", role: "assistant" }, finish_reason: "tool_calls" }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }, + null, + ]); + + const addedEvents = events.filter((e) => e.event === "response.output_item.added"); + const indexes = addedEvents.map((e) => e.data.output_index).sort((a, b) => a - b); + const sequential = indexes.map((_, i) => i); + assert.deepEqual( + indexes, + sequential, + `output_index values must be a gap-free 0..n-1 sequence (position === output_index is the Responses API's own contract); got ${JSON.stringify(indexes)}` + ); + + // The exact client-observable symptom: response.completed's output[] + // array, read by array position, must match each item's own tracked + // output_index -- otherwise a client keying by array position resolves + // the wrong item. + const completedGap = events.find((e) => e.event === "response.completed"); + completedGap.data.response.output.forEach((item, position) => { + const addedEvent = addedEvents.find((e) => e.data.item?.id === item.id); + assert.equal( + addedEvent?.data.output_index, + position, + `item ${item.id} (type ${item.type}) streamed at output_index ${addedEvent?.data.output_index} but sits at array position ${position} in the completed output` + ); + }); +}); From 2e4a79ca5022e4a564b94d3361ef5dd3e57652f0 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Thu, 3 Sep 2026 18:01:25 +0200 Subject: [PATCH 39/47] fix(quality): detect duplicate tool_calls entries in one response (#12446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem. O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. Obrigado, @hartmark. --- open-sse/handlers/chatCore/attemptLogging.ts | 10 ++++ .../chatCore/toolCallSpecViolationAudit.ts | 46 +++++++++++++++ open-sse/services/combo/validateQuality.ts | 50 +++++++++++++++- tests/unit/chatcore-attempt-logging.test.ts | 59 +++++++++++++++++++ 4 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 open-sse/handlers/chatCore/toolCallSpecViolationAudit.ts diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts index 65a492a541..07de9f19b2 100644 --- a/open-sse/handlers/chatCore/attemptLogging.ts +++ b/open-sse/handlers/chatCore/attemptLogging.ts @@ -13,6 +13,7 @@ import { extractProviderWarnings } from "@/lib/compliance/providerAudit"; import { logAuditEvent } from "@/lib/compliance"; import { emit } from "@/lib/events/eventBus"; +import { maybeLogToolCallSpecViolation } from "./toolCallSpecViolationAudit.ts"; import type { RequestCompletedPayload, RequestFailedPayload } from "@/lib/events/types"; import { saveCallLog } from "@/lib/usageDb"; import type { VideoBridgeLogRedactionEntry } from "@/lib/guardrails/videoBridge"; @@ -392,6 +393,15 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt }); } + maybeLogToolCallSpecViolation({ + responseBody, + provider, + model, + connectionId: finalConnectionId, + httpStatus: status, + requestId: skillRequestId, + }); + const capturedPipeline = reqLogger?.getPipelinePayloads?.() ?? null; const pipelinePayloads = detailedLoggingEnabled ? (capturedPipeline ?? {}) diff --git a/open-sse/handlers/chatCore/toolCallSpecViolationAudit.ts b/open-sse/handlers/chatCore/toolCallSpecViolationAudit.ts new file mode 100644 index 0000000000..573daae59e --- /dev/null +++ b/open-sse/handlers/chatCore/toolCallSpecViolationAudit.ts @@ -0,0 +1,46 @@ +/** + * Post-request on-spec audit for duplicated tool_calls. + * + * Extracted from persistAttemptLogs so attemptLogging.ts stays at the frozen + * complexity count. validateResponseQuality's streaming peek only sees the + * START of a stream, so a duplicate that arrives after real content has + * already been relayed cannot fail the attempt over — this is the first + * point the fully assembled body is available. Too late to retry; a durable + * audit row still beats a clean HTTP 200 with no trace. + * + * Observed: minimax-m3:free via OpenRouter/GMICloud, 2026-09-02, duplicated + * a heartbeat_respond call byte-for-byte. + */ + +import { logAuditEvent } from "@/lib/compliance"; +import { findToolCallSpecViolation } from "../../services/combo/validateQuality.ts"; + +export function maybeLogToolCallSpecViolation(input: { + responseBody: unknown; + provider: string | null | undefined; + model: string | null | undefined; + connectionId: string | null; + httpStatus: number; + requestId: string; +}): void { + const violation = findToolCallSpecViolation(input.responseBody); + if (!violation) return; + logAuditEvent({ + action: "provider.spec_violation", + actor: "system", + target: + [input.provider, input.connectionId].filter(Boolean).join(":") || + input.provider || + input.model, + resourceType: "provider_spec_violation", + status: "warning", + requestId: input.requestId, + details: { + provider: input.provider, + model: input.model, + connectionId: input.connectionId, + httpStatus: input.httpStatus, + violation, + }, + }); +} diff --git a/open-sse/services/combo/validateQuality.ts b/open-sse/services/combo/validateQuality.ts index be70038994..723a10b614 100644 --- a/open-sse/services/combo/validateQuality.ts +++ b/open-sse/services/combo/validateQuality.ts @@ -16,6 +16,45 @@ import { evaluateResponseValidation, type ResponseValidationConfig } from "./res import { getReasoningTokens } from "../../../src/lib/usage/tokenAccounting.ts"; import type { ComboRetryAfter } from "./types.ts"; +/** + * Detects tool_calls entries within one assistant message that repeat the + * exact same function name + arguments verbatim -- always a bug (no + * legitimate use calls one tool twice with identical arguments in the same + * turn), and a real observed failure mode of at least one free-tier + * streaming model (minimax-m3:free via OpenRouter/GMICloud, 2026-09-02: + * duplicated a heartbeat_respond call byte-for-byte, confirmed at the raw + * SSE wire level -- an upstream bug, not an OmniRoute reconstruction + * artifact). Used two ways: to fail a non-streaming response over to a + * sibling combo target (see validateResponseQuality below), and, post- + * stream, to flag an already-relayed streaming response as an on-spec + * violation despite its clean HTTP 200 (see attemptLogging.ts's + * persistAttemptLogs) -- a streaming response can't be retried once real + * content has started reaching the client (the quality-gate peek below only + * ever validates the START of a stream, by design, to avoid buffering the + * whole response and defeating streaming's latency purpose), so flagging it + * after the fact is what's actually achievable for that path. + */ +export function findToolCallSpecViolation(responseBody: unknown): string | null { + const json = isRecord(responseBody) ? responseBody : null; + const choices = json?.choices; + const firstChoice = Array.isArray(choices) ? choices[0] : null; + const message = isRecord(firstChoice) ? firstChoice.message : null; + const toolCalls = isRecord(message) ? message.tool_calls : null; + if (!Array.isArray(toolCalls) || toolCalls.length < 2) return null; + + const seen = new Set(); + for (const call of toolCalls) { + const fn = isRecord(call) ? call.function : null; + if (!isRecord(fn) || typeof fn.name !== "string" || typeof fn.arguments !== "string") { + continue; + } + const signature = `${fn.name}\u0000${fn.arguments}`; + if (seen.has(signature)) return `duplicate tool_calls entry for "${fn.name}"`; + seen.add(signature); + } + return null; +} + export function toRetryAfterDisplayValue(value: ComboRetryAfter): string | Date { if (typeof value !== "number") return value; if (value > 0 && value < 1_000_000_000) { @@ -327,9 +366,9 @@ export async function validateResponseQuality( function isTerminalUsageOnlyChunk(parsed: Record, eventType: string): boolean { return Boolean( parsed.usage && - typeof parsed.usage === "object" && - !Array.isArray(parsed.choices) && - !eventType.startsWith("response.") + typeof parsed.usage === "object" && + !Array.isArray(parsed.choices) && + !eventType.startsWith("response.") ); } @@ -734,6 +773,11 @@ export async function validateResponseQuality( } const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0; + const specViolation = findToolCallSpecViolation(json); + if (specViolation) { + return { valid: false, reason: specViolation }; + } + if (!hasContent && !hasToolCalls) { return { valid: false, reason: "empty content and no tool_calls in response" }; } diff --git a/tests/unit/chatcore-attempt-logging.test.ts b/tests/unit/chatcore-attempt-logging.test.ts index b9fe57fbd3..2af6bbd41c 100644 --- a/tests/unit/chatcore-attempt-logging.test.ts +++ b/tests/unit/chatcore-attempt-logging.test.ts @@ -16,6 +16,7 @@ process.env.DATA_DIR = testDataDir; const coreDb = await import("../../src/lib/db/core.ts"); const { getCallLogById } = await import("../../src/lib/usage/callLogs.ts"); const { persistAttemptLogs } = await import("../../open-sse/handlers/chatCore/attemptLogging.ts"); +const { getAuditLog } = await import("../../src/lib/compliance/index.ts"); type CodexRotationEnvelope = { _omniroute?: { @@ -136,3 +137,61 @@ test("connectionId falls back to credentials.connectionId when null, and error i assert.equal(row.status, 502); assert.match(String(row.error ?? ""), /upstream boom/); }); + +function duplicateHeartbeatBody() { + return { + choices: [ + { + message: { + tool_calls: [ + { function: { name: "heartbeat_respond", arguments: "{}" } }, + { function: { name: "heartbeat_respond", arguments: "{}" } }, + ], + }, + }, + ], + }; +} + +test("duplicate tool_calls in the assembled body writes provider.spec_violation audit", () => { + persistAttemptLogs( + { status: 200, responseBody: duplicateHeartbeatBody() }, + baseCtx({ pendingRequestId: "attempt-spec-violation-1", skillRequestId: "skill-spec-1" }) + ); + // logAuditEvent is synchronous; do not wait on the fire-and-forget saveCallLog. + const rows = getAuditLog({ action: "provider.spec_violation", requestId: "skill-spec-1" }); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.resourceType, "provider_spec_violation"); + const details = rows[0]?.details; + assert.ok(details && typeof details === "object"); + assert.equal( + (details as { violation?: string }).violation, + 'duplicate tool_calls entry for "heartbeat_respond"' + ); +}); + +test("unique tool_calls do not write provider.spec_violation audit", () => { + persistAttemptLogs( + { + status: 200, + responseBody: { + choices: [ + { + message: { + tool_calls: [ + { function: { name: "heartbeat_respond", arguments: "{}" } }, + { function: { name: "other_tool", arguments: "{}" } }, + ], + }, + }, + ], + }, + }, + baseCtx({ pendingRequestId: "attempt-spec-clean-1", skillRequestId: "skill-spec-clean-1" }) + ); + const rows = getAuditLog({ + action: "provider.spec_violation", + requestId: "skill-spec-clean-1", + }); + assert.equal(rows.length, 0); +}); From 0019a47f24099df09a03dca2af0e085209f4224c Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Thu, 3 Sep 2026 18:01:43 +0200 Subject: [PATCH 40/47] fix(responses-continuation): fail closed on a collector-truncated, empty output array (#12460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem. O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. Obrigado, @hartmark. --- src/lib/db/responsesContinuationStore.ts | 20 +++++++ .../unit/responses-continuation-store.test.ts | 58 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/lib/db/responsesContinuationStore.ts b/src/lib/db/responsesContinuationStore.ts index ef7a6e3de3..3e0f79b7ca 100644 --- a/src/lib/db/responsesContinuationStore.ts +++ b/src/lib/db/responsesContinuationStore.ts @@ -107,5 +107,25 @@ export function resolvePreviousResponseState( if (!Array.isArray(input) || !Array.isArray(output)) return null; if (containsTruncatedArrayMarker(input) || containsTruncatedArrayMarker(output)) return null; + // Live incident (2026-09-02): a huge/reasoning-heavy response can blow past + // createStructuredSSECollector's own event-count cap mid-stream -- the + // stored clientResponse then carries `_truncated: true` and + // `summary.status: "in_progress"` (never reached "completed") with a + // genuinely EMPTY `summary.output`, not a bounded array with a + // containsTruncatedArrayMarker sentinel (that marker only covers an + // array capped mid-array, not a collector that stopped before ever + // populating output at all). An empty output array passed the checks + // above and got merged into the next turn's request as this response's + // entire contribution -- reconstructing to zero real messages, which the + // upstream provider then rejected outright ("Input required: specify + // prompt or messages"), breaking the conversation with no client-visible + // continuation path. A response the client received as real (successful, + // non-empty) always has at least one output item; failing closed here + // makes the caller ask the client to resend full history instead of + // silently reconstructing an empty one, exactly like a real + // previous_response_not_found from OpenAI itself. + if ((clientResponse as { _truncated?: unknown } | undefined)?._truncated === true) return null; + if (output.length === 0) return null; + return { input, output }; } diff --git a/tests/unit/responses-continuation-store.test.ts b/tests/unit/responses-continuation-store.test.ts index 6c75d53c24..4f414c4e16 100644 --- a/tests/unit/responses-continuation-store.test.ts +++ b/tests/unit/responses-continuation-store.test.ts @@ -261,6 +261,64 @@ test("resolvePreviousResponseState fails closed when the stored input array was assert.equal(store.resolvePreviousResponseState("resp_gen-truncated-history", "key-1"), null); }); +test("resolvePreviousResponseState fails closed when the streaming collector truncated the response", () => { + // Live incident (2026-09-02): a huge/reasoning-heavy response blew past + // createStructuredSSECollector's own event-count cap mid-stream. The + // stored clientResponse then carries `_truncated: true` and + // `summary.status: "in_progress"` (never reached "completed") with a + // genuinely empty `summary.output` -- not a bounded array with an + // `_omniroute_truncated_array` sentinel (that only covers an array capped + // mid-array, not a collector that stopped before populating output at + // all). The empty array previously passed every check here and got + // merged into the next turn's request as this response's entire + // contribution -- reconstructing to zero real messages, which the + // upstream provider then rejected outright ("Input required: specify + // prompt or messages"), breaking the conversation. Measured live: ~22% + // of a sample of recent successful Ping responses carried this flag. + insertCallLog({ + id: "log-8", + responseId: "resp_gen-collector-truncated", + apiKeyId: "key-1", + detailState: "ready", + artifactRelPath: "2026-01-01/log-8.json", + }); + writeArtifact("2026-01-01/log-8.json", { + clientRawRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } }, + providerRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } }, + clientResponse: { + _streamed: true, + _truncated: true, + _droppedEvents: 24, + summary: { id: "resp_gen-collector-truncated", status: "in_progress", output: [] }, + }, + }); + + assert.equal(store.resolvePreviousResponseState("resp_gen-collector-truncated", "key-1"), null); +}); + +test("resolvePreviousResponseState fails closed on an empty output array even without the _truncated flag", () => { + // Belt-and-suspenders for the same failure class when the collector + // truncated without ever setting `_truncated` (or for a non-streaming + // response that somehow logged zero output items): a response the + // client actually received as real/successful always has at least one + // output item, so an empty array here is never a legitimate prior turn + // to reconstruct from. + insertCallLog({ + id: "log-9", + responseId: "resp_gen-empty-output", + apiKeyId: "key-1", + detailState: "ready", + artifactRelPath: "2026-01-01/log-9.json", + }); + writeArtifact("2026-01-01/log-9.json", { + clientRawRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } }, + providerRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } }, + clientResponse: { id: "resp_gen-empty-output", output: [] }, + }); + + assert.equal(store.resolvePreviousResponseState("resp_gen-empty-output", "key-1"), null); +}); + test("resolvePreviousResponseState returns null when detail logging was never captured for this row", () => { insertCallLog({ id: "log-5", From 87299096228a717d095e28430e1ac65aede6b847 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Thu, 3 Sep 2026 18:02:00 +0200 Subject: [PATCH 41/47] fix(logging): raise the SSE payload collector's default cap (#12461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem. O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. Obrigado, @hartmark. --- open-sse/utils/streamPayloadCollector.ts | 20 ++++++- .../unit/stream-payload-collector-cap.test.ts | 57 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 tests/unit/stream-payload-collector-cap.test.ts diff --git a/open-sse/utils/streamPayloadCollector.ts b/open-sse/utils/streamPayloadCollector.ts index c7aab45b7a..f0353ce0f5 100644 --- a/open-sse/utils/streamPayloadCollector.ts +++ b/open-sse/utils/streamPayloadCollector.ts @@ -885,8 +885,26 @@ export function compactStructuredStreamPayload(payload: unknown): unknown { }; } +// Live incident (2026-09-02): a reasoning-heavy response streams reasoning +// token-by-token as hundreds to thousands of tiny SSE deltas BEFORE the real +// output/tool_calls ever arrive. At the old defaults (200 events / 48KB) the +// cap was routinely exhausted during the reasoning phase alone, dropping the +// completion event entirely -- measured live: ~22% of a sample of recent +// successful responses hit this. For a caller with no `format` (no live +// reducer -- see the CollectorOptions.format doc comment), the logged +// summary is reconstructed from getEvents() (open-sse/utils/stream.ts), so a +// dropped completion event produced a served-successfully response logged +// with status "in_progress" and empty output -- which +// src/lib/db/responsesContinuationStore.ts then had nothing real to +// reconstruct a later continuation turn from (see its own fail-closed fix, +// 2026-09-02). Raising the cap doesn't eliminate the class of bug for an +// arbitrarily long stream, but it removes it as a routine, everyday failure; +// the format-driven live reducer (used by providerPayloadCollector, an +// analogous prior fix) is the cap-independent fix and remains the deeper +// follow-up for a caller that still wants build()'s summary correct beyond +// any fixed cap. export function createStructuredSSECollector(options: CollectorOptions = {}) { - const { maxEvents = 200, maxBytes = 49152, stage, format, fallbackModel } = options; + const { maxEvents = 2000, maxBytes = 524288, stage, format, fallbackModel } = options; const events: StructuredSSEEvent[] = []; let usedBytes = 0; let droppedEvents = 0; diff --git a/tests/unit/stream-payload-collector-cap.test.ts b/tests/unit/stream-payload-collector-cap.test.ts new file mode 100644 index 0000000000..a737c7a88d --- /dev/null +++ b/tests/unit/stream-payload-collector-cap.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { createStructuredSSECollector } = + await import("../../open-sse/utils/streamPayloadCollector.ts"); + +// Live incident (2026-09-02): a reasoning-heavy response streams reasoning +// token-by-token as hundreds to thousands of tiny SSE deltas before the real +// output/tool_calls ever arrive. At the old defaults (200 events / 48KB), +// the cap was routinely exhausted during the reasoning phase alone, +// dropping the completion event entirely -- for a caller with no `format` +// (no live reducer), the logged summary is reconstructed from getEvents() +// (see open-sse/utils/stream.ts), so the dropped completion silently +// produced a served-successfully response logged as _truncated with an +// empty output array. src/lib/db/responsesContinuationStore.ts then had +// nothing real to reconstruct a later continuation turn from. Measured +// live: ~22% of a sample of recent successful responses hit this. +test("createStructuredSSECollector retains a realistic reasoning-heavy event burst without dropping (regression for the 2026-09-02 truncated-continuation incident)", () => { + const collector = createStructuredSSECollector({ stage: "client_response" }); + + // Anonymized, real-incident shape: ~1600 small reasoning deltas (the + // observed volume for a genuinely reasoning-heavy turn) followed by the + // actual completion event -- exactly the ordering that exhausted the old + // 200-event/48KB cap before the completion event ever arrived. + for (let i = 0; i < 1600; i++) { + collector.push({ + type: "response.reasoning_summary_text.delta", + delta: "token ", + sequence_number: i, + }); + } + collector.push({ + type: "response.completed", + response: { id: "resp_test", status: "completed", output: [{ type: "message" }] }, + }); + + const built = collector.build(undefined, { includeEvents: true }); + assert.equal(built._truncated, undefined, "a realistic reasoning burst must not hit the cap"); + assert.equal(built._droppedEvents, undefined); + + const events = collector.getEvents(); + const completedEvent = events.find((e) => e.data?.type === "response.completed"); + assert.ok(completedEvent, "the completion event must survive to build()'s retained events"); +}); + +test("createStructuredSSECollector still reports _truncated once a stream genuinely exceeds the (raised) cap", () => { + // The cap protects against a truly pathological/runaway stream -- raising + // it must not remove that protection, only its false-positive rate on + // realistic reasoning-heavy traffic. + const collector = createStructuredSSECollector({ stage: "client_response", maxEvents: 5 }); + for (let i = 0; i < 10; i++) { + collector.push({ type: "response.output_text.delta", delta: "x", sequence_number: i }); + } + const built = collector.build(undefined, { includeEvents: false }); + assert.equal(built._truncated, true); + assert.equal(built._droppedEvents, 5); +}); From 4866f927ad1c80596e40272593df58bf1f2019aa Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Thu, 3 Sep 2026 18:02:24 +0200 Subject: [PATCH 42/47] =?UTF-8?q?fix(combo):=20universal-handoff=20fixes?= =?UTF-8?q?=20=E2=80=94=20bare-fallback=20note,=20same-request=20scoping,?= =?UTF-8?q?=20silent-failure=20logging=20(#12338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem. O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. Obrigado, @hartmark. --- open-sse/services/combo.ts | 19 ++++++- open-sse/services/contextHandoff.ts | 68 ++++++++++++++++++++------ tests/unit/combo-context-relay.test.ts | 15 ++++-- tests/unit/universal-handoff.test.ts | 8 ++- 4 files changed, 88 insertions(+), 22 deletions(-) diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index c18cd37333..be662c600f 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -1661,8 +1661,16 @@ async function handleComboChatInner({ } } - // Universal handoff: inject existing handoff if model changed + // Universal handoff: inject existing handoff if model changed. i === 0 + // only: a fallback target (i > 0) serves the SAME client request the + // failed primary target would have served, with the original messages + // already intact -- there's nothing to hand off, since the client never + // saw the earlier target fail. Injecting a handoff note there replaces + // real context with a context-free note, which weaker fallback models + // have been observed treating as license to fabricate content instead + // of just answering the actual request (#12227 follow-up). if ( + i === 0 && universalHandoffConfig.enabled && relayOptions?.sessionId && !(body as Record)?.[SKIP_UNIVERSAL_HANDOFF_FLAG] @@ -1928,7 +1936,14 @@ async function handleComboChatInner({ provider, target.connectionId ?? undefined ); - if (prevModel && prevModel !== modelStr) { + // i === 0 only: a same-request fallback target (i > 0) never + // needs a summary generated for it -- see the injection-site + // comment above. recordSessionModelUsage above stays + // unconditional regardless of i: it must reflect whichever + // model actually served THIS response, since the next + // request's i === 0 comparison depends on that being + // accurate even when this response came from a fallback. + if (i === 0 && prevModel && prevModel !== modelStr) { const handoffSourceMessages = Array.isArray(body?.messages) && body.messages.length > 0 ? body.messages diff --git a/open-sse/services/contextHandoff.ts b/open-sse/services/contextHandoff.ts index bd169be606..c43b7f55c1 100644 --- a/open-sse/services/contextHandoff.ts +++ b/open-sse/services/contextHandoff.ts @@ -407,7 +407,10 @@ async function generateHandoffAsync(options: { relayConfig.relayMode ); const historyText = formatMessagesForPrompt(selectedMessages); - if (!historyText) return; + if (!historyText) { + logUniversalHandoffOutcome("unavailable", options.comboName, "empty selected-message history"); + return; + } const summaryPrompt = HANDOFF_PROMPT_TEMPLATE.replace("{HISTORY}", historyText); const summaryBody = { @@ -421,7 +424,14 @@ async function generateHandoffAsync(options: { }; const response = await options.handleSingleModel(summaryBody, summaryModel); - if (!response.ok) return; + if (!response.ok) { + logUniversalHandoffOutcome( + "unavailable", + options.comboName, + `summary model call failed: status=${response.status} model=${summaryModel}` + ); + return; + } let content = ""; try { @@ -436,7 +446,14 @@ async function generateHandoffAsync(options: { } const parsed = parseHandoffJSON(content); - if (!parsed) return; + if (!parsed) { + logUniversalHandoffOutcome( + "unparseable", + options.comboName, + `model=${summaryModel} contentPreview=${JSON.stringify(content.slice(0, 200))}` + ); + return; + } upsertHandoff({ sessionId: options.sessionId, @@ -572,7 +589,7 @@ export function buildUniversalHandoffSystemMessage( ${escapedReason} ${escapedPrev} ${escapedCurr} -A continuación se resume toda la conversacion para continuar sin perder el hilo. +No prior-session summary is available for this handoff. The input below (e.g. a tool result) is the entire context you have -- do not assume or invent details about a broader conversation you cannot see. `; } @@ -687,6 +704,23 @@ export function resetUniversalHandoffCooldowns(): void { universalHandoffCooldowns.clear(); } +// Every non-"generated" outcome across both handoff generators (this one and +// the older generateHandoffAsync above) used to be silent -- context_handoffs +// staying empty gave no signal on WHY (upstream call failing vs. malformed +// output vs. no history to summarize). Every live handoff then falls back to +// the bare no-summary note (buildUniversalHandoffSystemMessage's `!payload` +// branch / the context-relay equivalent), which is what actually reaches the +// model/user; without this log that always reads as a mystery instead of a +// traceable cause. +function logUniversalHandoffOutcome( + outcome: "unavailable" | "unparseable", + comboName: string, + detail: string +): void { + if (process.env.NODE_ENV === "test") return; + console.warn(`[universal-handoff] ${outcome} (combo=${comboName}): ${detail}`); +} + /** * Generate a universal handoff summary for any model/provider switch. */ @@ -709,7 +743,10 @@ async function generateUniversalHandoffAsync(options: { options.relayMode ); const historyText = formatMessagesForPrompt(selectedMessages); - if (!historyText) return "unavailable"; + if (!historyText) { + logUniversalHandoffOutcome("unavailable", options.comboName, "empty selected-message history"); + return "unavailable"; + } const summaryPrompt = HANDOFF_PROMPT_TEMPLATE.replace("{HISTORY}", historyText); const summaryModel = options.handoffModel || options.currModel; @@ -735,22 +772,25 @@ async function generateUniversalHandoffAsync(options: { }; const response = await options.handleSingleModel(summaryBody, summaryModel); - if (!response.ok) return "unavailable"; + if (!response.ok) { + const detail = `summary model call failed: status=${response.status} model=${summaryModel}`; + logUniversalHandoffOutcome("unavailable", options.comboName, detail); + return "unavailable"; + } let content = ""; try { - const json = (await response.clone().json()) as Record; - content = getResponseText(json); + content = getResponseText((await response.clone().json()) as Record); } catch { - try { - content = await response.clone().text(); - } catch { - content = ""; - } + content = await response.clone().text().catch(() => ""); } const parsed = parseHandoffJSON(content); - if (!parsed) return "unparseable"; + if (!parsed) { + const preview = JSON.stringify(content.slice(0, 200)); + logUniversalHandoffOutcome("unparseable", options.comboName, `model=${summaryModel} contentPreview=${preview}`); + return "unparseable"; + } upsertHandoff({ sessionId: options.sessionId, diff --git a/tests/unit/combo-context-relay.test.ts b/tests/unit/combo-context-relay.test.ts index e984d7a696..82e88e6fd5 100644 --- a/tests/unit/combo-context-relay.test.ts +++ b/tests/unit/combo-context-relay.test.ts @@ -404,7 +404,14 @@ test("getLastSessionModel uses latest id as deterministic tie-breaker", async () assert.equal(handoffDb.getLastSessionModel(sessionId, comboName), "anthropic/new"); }); -test("handleComboChat universal handoff does not accumulate injected handoffs across fallback targets", async () => { +test("handleComboChat universal handoff skips same-request fallback targets entirely", async () => { + // #12227 follow-up: a same-request fallback target (i > 0) serves the SAME + // client request the failed primary target would have served -- the client + // never saw the earlier target fail, so there's no genuine "handoff" to + // explain. Injecting one there replaces real conversation content with a + // context-free note; weaker fallback models have been observed fabricating + // content instead of just answering the actual request when handed that + // note. The fallback target must receive the original request untouched. const sessionId = "sess-universal-no-mutate"; const comboName = "universal-no-mutate"; @@ -463,10 +470,8 @@ test("handleComboChat universal handoff does not accumulate injected handoffs ac typeof message?.content === "string" && message.content.includes("") ); - assert.equal(handoffMessages.length, 1); - assert.match(handoffMessages[0].content, /openai\/previous/); - assert.match(handoffMessages[0].content, /anthropic\/fallback/); - assert.doesNotMatch(handoffMessages[0].content, /openai\/failed/); + assert.equal(handoffMessages.length, 0); + assert.deepEqual(fallbackBody.messages, [{ role: "user", content: "Continue" }]); }); test("handleComboChat universal handoff detects model switch before recording current model", async () => { diff --git a/tests/unit/universal-handoff.test.ts b/tests/unit/universal-handoff.test.ts index 45c06f963f..addf4b633e 100644 --- a/tests/unit/universal-handoff.test.ts +++ b/tests/unit/universal-handoff.test.ts @@ -237,7 +237,13 @@ test("buildUniversalHandoffSystemMessage basic when payload null", () => { test("buildUniversalHandoffSystemMessage basic when payload summary empty", () => { const msg = buildUniversalHandoffSystemMessage(PREV, CURR, REASON, makePayload({ summary: "" })); - assert.ok(msg.includes("continuar sin perder el hilo")); + // The bare-fallback note must not claim continuity it can't provide: a + // model landing here with only trimmed input (e.g. a bare tool result) + // and no real history has been observed fabricating plausible-sounding + // but entirely invented content when told "the conversation continues + // without losing context" -- the note now tells it the opposite. + assert.ok(msg.includes("No prior-session summary is available")); + assert.ok(msg.includes("do not assume or invent")); }); test("buildUniversalHandoffSystemMessage full XML with valid payload", () => { From ffdc7360604fc4f831e1f992c94f1a5cbe610af0 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Thu, 3 Sep 2026 18:02:43 +0200 Subject: [PATCH 43/47] feat(dashboard): parent-link, genuine-continuation badge, and modal perf fixes (#12448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem. O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. Obrigado, @hartmark. --- .../dashboard/conversations/page.tsx | 128 +++++++++++++++--- src/app/api/conversations/[id]/route.ts | 36 +++++ src/app/api/conversations/route.ts | 66 ++++++--- src/app/api/logs/[id]/route.ts | 19 ++- src/lib/db/agenticConversations.ts | 74 +++++++--- src/lib/db/responsesContinuationStore.ts | 104 ++++++++++++++ .../RequestLoggerDetail.sections.tsx | 14 ++ 7 files changed, 381 insertions(+), 60 deletions(-) create mode 100644 src/app/api/conversations/[id]/route.ts diff --git a/src/app/(dashboard)/dashboard/conversations/page.tsx b/src/app/(dashboard)/dashboard/conversations/page.tsx index f9fb3b1848..14b8bdf4d7 100644 --- a/src/app/(dashboard)/dashboard/conversations/page.tsx +++ b/src/app/(dashboard)/dashboard/conversations/page.tsx @@ -26,6 +26,11 @@ interface ConversationRow { // streaming (call_logs only gets its row on completion). Used to poll // /api/logs/[id] for this conversation's live partial assistant text. activeCallLogId: string | null; + // Whether the latest turn actually used previous_response_id and it + // resolved server-side — distinct from this row existing at all, which + // only means the client-side content-hash tracker saw >= 2 turns + // regardless of transport (see isGenuineContinuationTurn). + isGenuineContinuation: boolean; } // Same spinner used for an in-flight request on /dashboard/logs @@ -101,6 +106,23 @@ function StatusBadge({ status }: { status: number | null }) { ); } +// Distinguishes a conversation whose latest turn actually used +// previous_response_id (server-verified — see isGenuineContinuationTurn) +// from one the content-hash tracker merely counts as multi-turn while still +// resending full history each request. +function ContinuationBadge({ isGenuine }: { isGenuine: boolean }) { + if (!isGenuine) return null; + return ( + + bolt + continuation + + ); +} + /** * Builds the exact NormalizedBlock (src/mitm/inspector/types.ts) the * request-detail panel already builds from buildRequestTurns/ @@ -268,13 +290,15 @@ function ConversationsPageContent() { // itself in the poll effect's dependency array (which would tear down and // restart the interval on every single appended turn). const newestSeqRef = useRef(null); + // Tracks the PREVIOUS render's activeCallLogId truthiness, so the + // reply-just-finished effect below can detect the true->false transition + // specifically (not "is currently falsy", which would also fire on mount + // / switching conversations). + const wasReplyActiveRef = useRef(false); - // Extracted so openConversation can force an immediate refresh instead of - // waiting for the next scheduled tick — see its call site for why: a - // conversation opened right after a new reply starts streaming otherwise - // shows no live text until this poll's own interval happens to land, - // because activeCallLogId only updates via the resync effect below, which - // depends on this list actually having been refetched. + // The background list poll below only runs this while no conversation + // modal is open — see loadActiveConversationSummary and the poll effect + // for the lighter single-row path used while one is open. const loadConversations = useCallback(() => { if (document.visibilityState !== "visible") return; return fetch("/api/conversations?limit=100", { cache: "no-store" }) @@ -290,22 +314,51 @@ function ConversationsPageContent() { }); }, []); + // While the modal is open, only the one open conversation's summary needs + // to stay live (see the resync effect below) — refetching and + // re-annotating the whole up-to-100-row list every poll tick just to pluck + // that one row back out is pure waste, and at a 1s poll interval it's + // waste on every tick. Patches the row in place so the existing resync + // effect (keyed on `conversations`) picks it up unchanged. + const loadActiveConversationSummary = useCallback((id: string) => { + if (document.visibilityState !== "visible") return; + return fetch(`/api/conversations/${id}`, { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + const fresh = data?.conversation; + if (!fresh) return; + setConversations((prev) => { + const idx = prev.findIndex((c) => c.id === fresh.id); + if (idx === -1) return prev; + const next = prev.slice(); + next[idx] = fresh; + return next; + }); + }) + .catch(() => {}); + }, []); + useEffect(() => { - loadConversations(); - const interval = setInterval(loadConversations, pollSeconds * 1000); + const poll = () => + activeConversationId + ? loadActiveConversationSummary(activeConversationId) + : loadConversations(); + poll(); + const interval = setInterval(poll, pollSeconds * 1000); return () => { clearInterval(interval); }; - }, [pollSeconds, loadConversations]); + }, [pollSeconds, loadConversations, loadActiveConversationSummary, activeConversationId]); // activeConversation is a snapshot taken once at openConversation() time — // it's never touched again while the modal stays open (the turns-poll // effect below only appends conversationNodes). Without this, "Goto latest // request" and any other displayed summary field (lastModel/lastStatus/ // turnCount) go stale the moment a new request lands in this conversation - // while you're still reading it, even though the list poll above (which - // runs regardless of whether the modal is open) already has the fresh - // row. Re-sync from it whenever the list refreshes. + // while you're still reading it. Re-synced from `conversations` whenever + // that refreshes — the effect above keeps it fresh whether the modal is + // closed (full list poll) or open (single-conversation poll patches this + // same row in place). useEffect(() => { if (!activeConversationId) return; const fresh = conversations.find((c) => c.id === activeConversationId); @@ -461,13 +514,13 @@ function ConversationsPageContent() { // ignore navigation errors } // `row` is a snapshot from whenever the list last polled — if a reply - // started streaming after that tick, row.activeCallLogId is still - // null and the live-text poll effect never starts until the next - // scheduled list refresh happens to land (the exact "opened it and - // saw nothing, closed and reopened and saw it live" report). Force - // one now so activeConversation resyncs with the current isActive/ - // activeCallLogId immediately instead of waiting on pollSeconds. - loadConversations(); + // started streaming after that tick, row.activeCallLogId is still null + // and the live-text poll effect never starts until a fresh summary + // lands (the exact "opened it and saw nothing, closed and reopened and + // saw it live" report). setActiveConversation above already changes + // activeConversationId, which is a dependency of the poll effect below + // — it tears down and re-fires immediately on that change, forcing the + // single-row resync here for free without a second explicit call. fetchConversationPage(row.id, `limit=${CONVERSATION_PAGE_SIZE}`) .then((page) => { setConversationNodes(page?.nodes ?? []); @@ -480,7 +533,7 @@ function ConversationsPageContent() { scrollToBottom(); }); }, - [router, fetchConversationPage, scrollToBottom, loadConversations] + [router, fetchConversationPage, scrollToBottom] ); const closeConversation = useCallback(() => { @@ -583,6 +636,35 @@ function ConversationsPageContent() { return () => clearInterval(interval); }, [activeConversationId, pollSeconds, fetchConversationPage]); + // Live incident (2026-09-02): resolveConversationId reassigns a node's + // last_correlation_id to the CURRENT request at request-START (before its + // reply streams), but that request's call-log artifact -- what + // resolveTurnDisplayContent needs to show real text -- is only written at + // completion. A node touched by a still-in-flight request therefore + // legitimately resolves empty if fetched during that window; the afterSeq + // poll above only ever APPENDS strictly newer nodes, so one already + // rendered empty stays empty in local state forever, even once its + // artifact exists moments later -- the exact "empty until you close and + // reopen the conversation" symptom. Once a reply that was streaming + // finishes (activeCallLogId's true -> false transition -- see the + // wasReplyActiveRef doc comment), re-fetch the recent page and merge it in + // by id (never drop older "Load more" history) so any node that resolved + // empty during the race gets its real content without a manual reopen. + useEffect(() => { + const wasActive = wasReplyActiveRef.current; + wasReplyActiveRef.current = Boolean(activeCallLogId); + if (!wasActive || activeCallLogId || !activeConversationId) return; + + fetchConversationPage(activeConversationId, `limit=${CONVERSATION_PAGE_SIZE}`).then((page) => { + if (!page || page.nodes.length === 0) return; + setConversationNodes((prev) => { + const byId = new Map(prev.map((n) => [n.id, n] as const)); + for (const n of page.nodes) byId.set(n.id, n); + return [...byId.values()].sort((a, b) => a.seq - b.seq); + }); + }); + }, [activeCallLogId, activeConversationId, fetchConversationPage]); + // Live preview of the CURRENTLY streaming reply, if any: conversation_turn_nodes // only gains a node for an assistant turn once the client resends it as // history on its NEXT request (resolveConversationId reads only the request @@ -655,6 +737,7 @@ function ConversationsPageContent() { lastStatus: null, isActive: false, activeCallLogId: null, + isGenuineContinuation: false, } ); }, [initialConversationParam, loading, conversations, openConversation]); @@ -759,6 +842,7 @@ function ConversationsPageContent() { > {row.id.slice(0, 16)}… + {row.turnCount} turns @@ -785,6 +869,7 @@ function ConversationsPageContent() { Conversation Turns + Continuation Last Model Provider Status @@ -816,6 +901,9 @@ function ConversationsPageContent() { {row.turnCount} + + + {row.lastModel ?? "—"} diff --git a/src/app/api/conversations/[id]/route.ts b/src/app/api/conversations/[id]/route.ts new file mode 100644 index 0000000000..999e2147df --- /dev/null +++ b/src/app/api/conversations/[id]/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getMultiTurnConversationById } from "@/lib/db/agenticConversations"; +import { annotateConversationRow, buildActiveCallLogIdByConversation } from "../route"; + +export const dynamic = "force-dynamic"; + +/** + * Single-conversation summary — used by the dashboard's conversation modal + * to keep lastModel/lastStatus/isActive/activeCallLogId fresh on the auto- + * refresh interval while it's open, instead of the list route re-fetching + * and re-annotating up to 200 rows just to pluck one back out. The turns + * themselves live-update through the separate .../tree poll; this only + * covers the summary fields the modal header and "Goto latest request" + * read off the row. + */ +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(req); + if (authError) return authError; + + try { + const { id } = await params; + if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 }); + + const row = getMultiTurnConversationById(id); + if (!row) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const activeCallLogIdByConversation = buildActiveCallLogIdByConversation(); + const conversation = annotateConversationRow(row, activeCallLogIdByConversation); + + return NextResponse.json({ conversation }); + } catch (err) { + console.error("[API ERROR] /api/conversations/[id] failed:", err); + return NextResponse.json({ error: "Failed to fetch conversation" }, { status: 500 }); + } +} diff --git a/src/app/api/conversations/route.ts b/src/app/api/conversations/route.ts index 9b5ebc3746..e603460dfb 100644 --- a/src/app/api/conversations/route.ts +++ b/src/app/api/conversations/route.ts @@ -1,10 +1,53 @@ import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import type { MultiTurnConversationRow } from "@/lib/db/agenticConversations"; import { listMultiTurnConversations } from "@/lib/db/agenticConversations"; import { getPendingById } from "@/lib/usage/usageHistory"; +import { isGenuineContinuationTurn } from "@/lib/db/responsesContinuationStore"; export const dynamic = "force-dynamic"; +/** + * Shared row -> API-shape annotation for both the list route and the + * single-conversation route below: isActive/activeCallLogId (pending-request + * cross reference) and isGenuineContinuation (artifact-backed, cached — see + * isGenuineContinuationTurn) need the exact same computation regardless of + * whether the caller asked for one row or many. Strips the internal-only + * lastArtifactRelPath/lastApiKeyId fields before they reach the client. + */ +export function annotateConversationRow( + row: MultiTurnConversationRow, + activeCallLogIdByConversation: ReadonlyMap +) { + const { lastArtifactRelPath, lastApiKeyId, ...rest } = row; + return { + ...rest, + isActive: activeCallLogIdByConversation.has(row.id), + activeCallLogId: activeCallLogIdByConversation.get(row.id) ?? null, + isGenuineContinuation: isGenuineContinuationTurn(lastArtifactRelPath, lastApiKeyId), + }; +} + +/** + * A pending (still-streaming) request's sessionTag is the conversation's own + * id (agentic_conversations.id === call_logs.session_tag) — cross reference + * so a conversation row can show "in progress" without a separate poll. + * `call_logs` only gets its row on completion (src/lib/usage/callLogs.ts's + * INSERT needs duration/status/tokens, none of which exist yet), so + * lastCallLogId always lags one request behind while a reply is still + * streaming — it can't be used to fetch the in-flight response. Surfacing + * the pending request's own id separately lets the conversation panel poll + * /api/logs/[id] for it directly (same live-partial-text path + * RequestLoggerDetail already uses). + */ +export function buildActiveCallLogIdByConversation(): Map { + const map = new Map(); + for (const pending of getPendingById().values()) { + if (pending.sessionTag) map.set(pending.sessionTag, pending.id); + } + return map; +} + export async function GET(req: Request) { const authError = await requireManagementAuth(req); if (authError) return authError; @@ -19,25 +62,10 @@ export async function GET(req: Request) { offset: Number.isFinite(offset) ? offset : undefined, }); - // A pending (still-streaming) request's sessionTag is the conversation's - // own id (agentic_conversations.id === call_logs.session_tag) — cross - // reference so the list can show "in progress" without a separate poll. - // `call_logs` only gets its row on completion (src/lib/usage/callLogs.ts's - // INSERT needs duration/status/tokens, none of which exist yet), so - // `lastCallLogId` from listMultiTurnConversations always lags one request - // behind while a reply is still streaming — it can't be used to fetch the - // in-flight response. Surface the pending request's own id separately so - // the conversation panel can poll /api/logs/[id] for it directly (same - // live-partial-text path RequestLoggerDetail already uses). - const activeCallLogIdByConversation = new Map(); - for (const pending of getPendingById().values()) { - if (pending.sessionTag) activeCallLogIdByConversation.set(pending.sessionTag, pending.id); - } - const conversations = rows.map((row) => ({ - ...row, - isActive: activeCallLogIdByConversation.has(row.id), - activeCallLogId: activeCallLogIdByConversation.get(row.id) ?? null, - })); + const activeCallLogIdByConversation = buildActiveCallLogIdByConversation(); + const conversations = rows.map((row) => + annotateConversationRow(row, activeCallLogIdByConversation) + ); return NextResponse.json({ conversations, total }); } catch (err) { diff --git a/src/app/api/logs/[id]/route.ts b/src/app/api/logs/[id]/route.ts index 7c20cfb819..afdb7d2432 100644 --- a/src/app/api/logs/[id]/route.ts +++ b/src/app/api/logs/[id]/route.ts @@ -2,6 +2,10 @@ import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { getCallLogById } from "@/lib/usageDb"; import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory"; +import { + extractPreviousResponseId, + resolveCallLogIdByResponseId, +} from "@/lib/db/responsesContinuationStore"; // Each logged chunk-array element is one raw network read, timestamp-prefixed // for the debug display — NOT one complete SSE `data:` line. A single JSON @@ -159,7 +163,20 @@ export async function GET( if (!persistedRequest) return NextResponse.json({ error: "Not found" }, { status: 404 }); - return NextResponse.json(persistedRequest); + // "Continues from" link for the dashboard's conversation panel: resolve + // this entry's own previous_response_id back to the call-log row that + // produced it. Persisted-only (apiKeyId isn't plumbed onto the + // pending/in-memory branches above) -- required for the same tenant + // scoping resolveCallLogIdByResponseId enforces, so an active/in-memory + // entry simply renders no parent link rather than resolving unscoped. + const previousResponseId = extractPreviousResponseId( + persistedRequest.pipelinePayloads as Record | null | undefined + ); + const parentLogId = previousResponseId + ? resolveCallLogIdByResponseId(previousResponseId, persistedRequest.apiKeyId ?? null) + : null; + + return NextResponse.json({ ...persistedRequest, previousResponseId, parentLogId }); } catch (err) { console.error("[API ERROR] /api/logs/[id] failed:", err); return NextResponse.json({ error: "Failed to fetch log" }, { status: 500 }); diff --git a/src/lib/db/agenticConversations.ts b/src/lib/db/agenticConversations.ts index 646fce57a7..0bdb9ff4e2 100644 --- a/src/lib/db/agenticConversations.ts +++ b/src/lib/db/agenticConversations.ts @@ -357,6 +357,12 @@ export interface MultiTurnConversationRow extends AgenticConversationRow { lastModel: string | null; lastProvider: string | null; lastStatus: number | null; + // Exposed so the API layer can check whether the latest turn genuinely + // used HTTP continuation (see isGenuineContinuationTurn in + // responsesContinuationStore.ts) without a second query — this row's own + // artifact/tenant already identify it, no separate lookup needed. + lastArtifactRelPath: string | null; + lastApiKeyId: string | null; } /** @@ -400,16 +406,7 @@ export function listMultiTurnConversations( const rows = db .prepare( - `SELECT ac.*, latest.id as last_call_log_id, latest.model as last_model, - latest.provider as last_provider, latest.status as last_status - FROM agentic_conversations ac - LEFT JOIN ( - SELECT cl1.id, cl1.session_tag, cl1.model, cl1.provider, cl1.status - FROM call_logs cl1 - WHERE cl1.timestamp = ( - SELECT MAX(cl2.timestamp) FROM call_logs cl2 WHERE cl2.session_tag = cl1.session_tag - ) - ) latest ON latest.session_tag = ac.id + `${MULTI_TURN_CONVERSATION_SELECT} WHERE (SELECT COUNT(*) FROM conversation_turn_nodes n WHERE n.conversation_id = ac.id) >= 2 ORDER BY ac.last_seen_at DESC LIMIT ? OFFSET ?` @@ -418,15 +415,52 @@ export function listMultiTurnConversations( return { total: Number(total ?? 0), - rows: rows.map((r) => { - const rec = asRecord(r); - return { - ...toRow(rec), - lastCallLogId: typeof rec.last_call_log_id === "string" ? rec.last_call_log_id : null, - lastModel: typeof rec.last_model === "string" ? rec.last_model : null, - lastProvider: typeof rec.last_provider === "string" ? rec.last_provider : null, - lastStatus: typeof rec.last_status === "number" ? rec.last_status : null, - }; - }), + rows: rows.map(toMultiTurnConversationRow), }; } + +function toMultiTurnConversationRow(value: unknown): MultiTurnConversationRow { + const rec = asRecord(value); + return { + ...toRow(rec), + lastCallLogId: typeof rec.last_call_log_id === "string" ? rec.last_call_log_id : null, + lastModel: typeof rec.last_model === "string" ? rec.last_model : null, + lastProvider: typeof rec.last_provider === "string" ? rec.last_provider : null, + lastStatus: typeof rec.last_status === "number" ? rec.last_status : null, + lastArtifactRelPath: + typeof rec.last_artifact_relpath === "string" ? rec.last_artifact_relpath : null, + lastApiKeyId: typeof rec.last_api_key_id === "string" ? rec.last_api_key_id : null, + }; +} + +const MULTI_TURN_CONVERSATION_SELECT = ` + SELECT ac.*, latest.id as last_call_log_id, latest.model as last_model, + latest.provider as last_provider, latest.status as last_status, + latest.artifact_relpath as last_artifact_relpath, + latest.api_key_id as last_api_key_id + FROM agentic_conversations ac + LEFT JOIN ( + SELECT cl1.id, cl1.session_tag, cl1.model, cl1.provider, cl1.status, + cl1.artifact_relpath, cl1.api_key_id + FROM call_logs cl1 + WHERE cl1.timestamp = ( + SELECT MAX(cl2.timestamp) FROM call_logs cl2 WHERE cl2.session_tag = cl1.session_tag + ) + ) latest ON latest.session_tag = ac.id +`; + +/** + * Single-conversation equivalent of listMultiTurnConversations, for the + * dashboard's conversation modal: while it's open, polling this one row on + * the refresh interval (instead of the whole up-to-200-row list just to + * pluck one row back out of it) is what actually needs to stay live — + * lastCallLogId/lastStatus for "Goto latest request" and isActive detection. + * Unlike the list, this intentionally has no turn-count floor: a + * specifically-requested conversation should resolve even if it hasn't (yet) + * reached 2 turn nodes. + */ +export function getMultiTurnConversationById(id: string): MultiTurnConversationRow | null { + const db = getDbInstance(); + const row = db.prepare(`${MULTI_TURN_CONVERSATION_SELECT} WHERE ac.id = ?`).get(id); + return row ? toMultiTurnConversationRow(row) : null; +} diff --git a/src/lib/db/responsesContinuationStore.ts b/src/lib/db/responsesContinuationStore.ts index 3e0f79b7ca..9c55d8edff 100644 --- a/src/lib/db/responsesContinuationStore.ts +++ b/src/lib/db/responsesContinuationStore.ts @@ -129,3 +129,107 @@ export function resolvePreviousResponseState( return { input, output }; } + +/** + * Resolve the call-log id that produced `responseId`, for the dashboard's + * "continues from" link. Reuses the same `call_logs.response_id` index and + * `api_key_id` tenant scoping as `resolvePreviousResponseState` above -- a + * parent link must never point across API keys, even just to surface its id. + * Returns null on any lookup miss so the caller renders no link rather than + * a broken one. + */ +export function resolveCallLogIdByResponseId( + responseId: string, + apiKeyId: string | null | undefined +): string | null { + if (!responseId || !apiKeyId) return null; + + const db = getDbInstance(); + const row = db + .prepare( + `SELECT id FROM call_logs + WHERE response_id = ? AND api_key_id = ? + ORDER BY timestamp DESC LIMIT 1` + ) + .get(responseId, apiKeyId) as { id: string } | undefined; + + return row?.id ?? null; +} + +/** + * Extract `previous_response_id` from a call-log's own pipeline payload. + * Persisted artifacts key the client's own request `clientRawRequest`; the + * pending/in-flight in-memory shape keys the same thing `clientRequest` + * instead (RequestLoggerDetail.tsx's payloadSections list carries both keys + * for the same reason) -- check both so callers get the same answer + * regardless of which shape the payload came back as. + */ +export function extractPreviousResponseId( + pipelinePayloads: Record | null | undefined +): string | null { + if (!pipelinePayloads) return null; + for (const key of ["clientRawRequest", "clientRequest"]) { + const envelope = pipelinePayloads[key]; + const body = isPlainRecord(envelope) && "body" in envelope ? envelope.body : envelope; + if (isPlainRecord(body) && typeof body.previous_response_id === "string") { + return body.previous_response_id; + } + } + return null; +} + +// isGenuineContinuationTurn is a pure function of one call-log's own +// artifact, which is immutable once written (see callLogs.ts -- detailState +// only flips to "ready" after the artifact is fully persisted) -- the same +// artifactRelPath always answers the same way, forever. Without this cache, +// the dashboard's own default auto-refresh polls the whole conversation list +// on an interval the operator controls (down to 1s), so every tick re-reads +// and re-parses one artifact per visible row for an answer that can never +// change once computed. Keyed on artifactRelPath alone (1:1 with the owning +// call-log row, so apiKeyId never varies for a given key) with simple FIFO +// eviction -- correctness never depends on which entries survive, only on +// staying bounded. +const GENUINE_CONTINUATION_CACHE_MAX = 5000; +const genuineContinuationCache = new Map(); + +function cacheGenuineContinuation(key: string, value: boolean): boolean { + genuineContinuationCache.set(key, value); + if (genuineContinuationCache.size > GENUINE_CONTINUATION_CACHE_MAX) { + const oldest = genuineContinuationCache.keys().next().value; + if (oldest !== undefined) genuineContinuationCache.delete(oldest); + } + return value; +} + +/** + * Whether a call-log's own request genuinely continued a prior response + * server-side: it carried `previous_response_id` AND that id resolved to a + * real, same-tenant prior call-log row. Backs the /dashboard/conversations + * "genuine continuation" badge -- a conversation the client-side turn + * tracker counts as multi-turn (conversationTracker.ts's content-hash chain, + * independent of transport) is not necessarily one actually running on the + * `previous_response_id` wire optimization; this checks the transport fact, + * not the content-hash one. + */ +export function isGenuineContinuationTurn( + artifactRelPath: string | null | undefined, + apiKeyId: string | null | undefined +): boolean { + if (!artifactRelPath) return false; + const cached = genuineContinuationCache.get(artifactRelPath); + if (cached !== undefined) return cached; + + const { artifact, state } = readCallArtifact(artifactRelPath); + if (state !== "ready" || !artifact?.pipeline) { + return cacheGenuineContinuation(artifactRelPath, false); + } + const previousResponseId = extractPreviousResponseId( + artifact.pipeline as Record + ); + if (!previousResponseId) return cacheGenuineContinuation(artifactRelPath, false); + + return cacheGenuineContinuation( + artifactRelPath, + resolveCallLogIdByResponseId(previousResponseId, apiKeyId) !== null + ); +} diff --git a/src/shared/components/RequestLoggerDetail.sections.tsx b/src/shared/components/RequestLoggerDetail.sections.tsx index e7a145638d..78527ab495 100644 --- a/src/shared/components/RequestLoggerDetail.sections.tsx +++ b/src/shared/components/RequestLoggerDetail.sections.tsx @@ -259,6 +259,20 @@ export function ConversationContextSection({ log, detail }) { {open ? "expand_less" : "expand_more"} + {liveDetail?.parentLogId && ( + // Full navigation, not client-side routing: the logs page only reads + // ?id from a fresh mount (useState(() => searchParams.get("id")) in + // dashboard/logs/page.tsx), so an in-page route change wouldn't load + // the parent entry if the user is already on this page. + + reply + continues from parent + + )}
{open && (
From 1baa8c36304bfda49d20185b9e36d65d0bbda667 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 3 Sep 2026 13:02:52 -0300 Subject: [PATCH 44/47] fix(quality): re-point the zcodeProtocol public-creds allowlist to line 313 (#12615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:public-creds` has been failing on every open PR against release/v3.8.51, twice over for the same literal: ✗ 1 entrada(s) obsoleta(s) na allowlist — zcodeProtocol.ts:302 ✗ 1 credencial(is) pública(s) como string literal — zcodeProtocol.ts L313 Both are the same `clientId: \`omniroute-${process.pid}\`` in the local ZCode handshake. Nothing regressed: the allowlist key is `file:line:value`, so an edit that shifted the statement from 302 to 313 invalidated the frozen key and the gate reported the entry as stale AND the literal as new. Re-pointed the key and its comment. The literal itself is unchanged and still frozen — the entry is not removed and the detector is not weakened (the gate's own test still asserts that renaming the value to `upstream-client-` is flagged). `tests/unit/check-public-creds.test.ts` synthesizes the source with a newline count to land the statement on the allowlisted line; that count moves with it, 302 -> 313, so the test keeps pinning the real contract instead of a stale one. Also documented the sharp edge inline: keying by line number means any edit near this statement breaks the gate in two places at once, and the fix is to re-point the line, never to drop the entry. Tightening the key to `file:value` would remove the trap but widens what the entry freezes, so it is left as a note rather than folded into a base-red drain. check:public-creds OK (3 frozen literals), check-public-creds tests 20/20, check:tracked-artifacts OK, prettier clean. --- scripts/check/check-public-creds.mjs | 8 ++++++-- tests/unit/check-public-creds.test.ts | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/check/check-public-creds.mjs b/scripts/check/check-public-creds.mjs index 7e065705d0..62549001bb 100644 --- a/scripts/check/check-public-creds.mjs +++ b/scripts/check/check-public-creds.mjs @@ -90,14 +90,18 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/; // The MiniMax family was extracted from services/usage.ts into services/usage/minimax.ts // (god-file decomposition), so the FP moved with the getMiniMaxUsage signature. // -// open-sse/executors/zcodeProtocol.ts L302: `clientId: \`omniroute-${process.pid}\`` +// open-sse/executors/zcodeProtocol.ts L313: `clientId: \`omniroute-${process.pid}\`` // is the per-process identifier in the local ZCode app-server handshake. It is // generated from the process PID, is not an upstream OAuth/client credential, and // must remain visible in the wire contract. Frozen by file:line:value key. +// NOTE: the key includes the LINE, so any edit that shifts this statement breaks +// the gate twice over — a stale-entry error plus a "new violation" for the same +// literal. That is what happened here (L302 -> L313). Re-point the line; do not +// remove the entry. export const KNOWN_LITERAL_CREDS = new Set([ "open-sse/services/usage/minimax.ts:213:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (getMiniMaxUsage signature) "open-sse/services/usage/minimax.ts:213:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (getMiniMaxUsage signature) - "open-sse/executors/zcodeProtocol.ts:302:omniroute-${process.pid}", // local per-process ZCode handshake ID, not an upstream credential + "open-sse/executors/zcodeProtocol.ts:313:omniroute-${process.pid}", // local per-process ZCode handshake ID, not an upstream credential ]); /** diff --git a/tests/unit/check-public-creds.test.ts b/tests/unit/check-public-creds.test.ts index 5531c5d336..b726218495 100644 --- a/tests/unit/check-public-creds.test.ts +++ b/tests/unit/check-public-creds.test.ts @@ -68,7 +68,10 @@ test("allowlist freezes a literal by file:line:value key", () => { }); test("allowlist preserves the local ZCode handshake client ID without weakening credential detection", () => { - const src = `${"\n".repeat(301)}clientId: \`omniroute-\${process.pid}\`,`; + // 312 newlines puts the statement on line 313, which is where it lives in + // zcodeProtocol.ts today. The allowlist key carries the line number, so this + // literal has to be kept in step with the source (it moved 302 -> 313). + const src = `${"\n".repeat(312)}clientId: \`omniroute-\${process.pid}\`,`; assert.deepEqual( findLiteralCreds(src, KNOWN_LITERAL_CREDS, "open-sse/executors/zcodeProtocol.ts"), [] From ad500de9e3b4ece9cab24ccec5678c1b0851e015 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 3 Sep 2026 13:04:34 -0300 Subject: [PATCH 45/47] chore(quality): rebaseline file-size caps the hartmark batch grew past (#12623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebaseline medido no tip com os 9 PRs da leva hartmark mergeados. Não toca codex.ts nem stream.ts, drift anterior à leva. --- changelog.d/maintenance/hartmark-batch-filesize.md | 1 + config/quality/file-size-baseline.json | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 changelog.d/maintenance/hartmark-batch-filesize.md diff --git a/changelog.d/maintenance/hartmark-batch-filesize.md b/changelog.d/maintenance/hartmark-batch-filesize.md new file mode 100644 index 0000000000..d55f5b4029 --- /dev/null +++ b/changelog.d/maintenance/hartmark-batch-filesize.md @@ -0,0 +1 @@ +- **chore(quality):** rebaseline the file-size caps the hartmark batch grew past (`combos/page.tsx` via [#12355](https://github.com/diegosouzapw/OmniRoute/pull/12355), `open-sse/services/combo.ts` via [#12338](https://github.com/diegosouzapw/OmniRoute/pull/12338)) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 86ea2f3a86..b8415c5e8d 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -422,7 +422,7 @@ "open-sse/mcp-server/server.ts": 1572, "open-sse/services/accountFallback.ts": 2467, "open-sse/services/adobeFireflyBrowserLogin.ts": 1401, - "open-sse/services/combo.ts": 4023, + "open-sse/services/combo.ts": 4036, "open-sse/translator/response/openai-responses.ts": 1466, "open-sse/utils/cursorAgentProtobuf.ts": 1547, "open-sse/utils/proxyFetch.ts": 1271, @@ -431,7 +431,7 @@ "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1322, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1344, "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3186, - "src/app/(dashboard)/dashboard/combos/page.tsx": 5012, + "src/app/(dashboard)/dashboard/combos/page.tsx": 5018, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1319, "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2491, "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1631, @@ -634,5 +634,6 @@ "_rebaseline_2026_08_30_11771_vercel_gateway_passthrough": "PR #11771 adds passthroughModels: true (1 line) to the Vercel AI Gateway registry entry — no split available, single-line provider-config addition.", "_relax_velocity_2026_08_30": "127 frozen line caps and cap/testCap raised by 20% (velocity phase; see quality-baseline.json _policy).", "_rebaseline_2026_09_02_12325_merge_v3851": "Merge of release/v3.8.51 into #12325. Both sides grew chatCore.ts at the same chokepoint: #12239 took it 5946->5976 upstream, and this PR adds its +9 non-Codex 429 branch on top. check-file-size.mjs counts split(\"\\\\n\").length (trailing-newline empty element), so the merged file is 5981. The cap is the merged LOC, not either side alone; no other entry moves.", - "_rebaseline_2026_09_03_houminxi_batch_stacked": "Crescimento medido DEPOIS que os 9 PRs da leva HouMinXi entraram, quando cada um empilhou sobre o rebaseline do anterior: providers/page.tsx 2007->2025 (+18 = feedback de erro por linha do import CSV do #12504 somado a busca por nome/baseUrl do #12495, ambos no mesmo painel de conexoes); chatCore.ts 5981->5984 (+3 = o #12325 invalida o cache generico de quota no 429 upstream, ao lado do ramo Codex ja existente); accountFallback.ts 2461->2467 (+6 = o #12566 empilha a carve-out de familia Antigravity sobre o rebaseline 2422->2461 que o #12590 registrou para o carve-out credits_exhausted da Moonshot; os dois tocam checkFallbackError). Cada PR mediu certo isoladamente, mas nenhum enxergava o empilhamento. Fiacao em chokepoints existentes. NAO cobre codex.ts nem stream.ts, que ja violavam no tip antes desta leva (drift da base)." + "_rebaseline_2026_09_03_houminxi_batch_stacked": "Crescimento medido DEPOIS que os 9 PRs da leva HouMinXi entraram, quando cada um empilhou sobre o rebaseline do anterior: providers/page.tsx 2007->2025 (+18 = feedback de erro por linha do import CSV do #12504 somado a busca por nome/baseUrl do #12495, ambos no mesmo painel de conexoes); chatCore.ts 5981->5984 (+3 = o #12325 invalida o cache generico de quota no 429 upstream, ao lado do ramo Codex ja existente); accountFallback.ts 2461->2467 (+6 = o #12566 empilha a carve-out de familia Antigravity sobre o rebaseline 2422->2461 que o #12590 registrou para o carve-out credits_exhausted da Moonshot; os dois tocam checkFallbackError). Cada PR mediu certo isoladamente, mas nenhum enxergava o empilhamento. Fiacao em chokepoints existentes. NAO cobre codex.ts nem stream.ts, que ja violavam no tip antes desta leva (drift da base).", + "_rebaseline_2026_09_03_hartmark_batch": "Leva hartmark (#12293 #12355 #12447 #12445 #12446 #12460 #12461 #12338 #12448) crescimento proprio, medido no tip com os nove mergeados: src/app/(dashboard)/dashboard/combos/page.tsx 5012->5018 (+6, #12355 impede que a falha de bundling do tiktoken de um provider sem relacao derrube /api/providers, e o painel passa a lidar com o estado degradado); open-sse/services/combo.ts 4023->4036 (+13, #12338 nos fixes do universal-handoff: nota de bare-fallback, escopo por mesma requisicao e log da falha silenciosa). Fiacao em chokepoints existentes do roteamento de combo. NAO cobre codex.ts nem stream.ts, ja violando no tip antes desta leva (drift da base)." } From f51cd295c800cb3c3182affe916b236a5aa1ce08 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 3 Sep 2026 13:49:46 -0300 Subject: [PATCH 46/47] chore(providers): bump Claude Code wire identity + Devin bridge pin to 2.1.258 (#12604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 17 de 18 checks verdes, **zero falhas** — incluindo os quatro shards de unit, Vitest, CodeQL, semgrep, Docs Gates, Merge integrity e o **Fast Quality Gates**, que era exatamente o que o rebaseline do `RoutingTab.tsx` (1606→1607, a linha do seletor que acompanha a nova versão de identidade) veio consertar. O único check restante, `No new ESLint warnings`, ficou enfileirado sem iniciar (duração 0) atrás da saturação de runners de hoje. Cobri os dois comandos que ele executa, localmente e sobre este HEAD: - `npm run check:codeql-ratchet` → **0 alertas abertos** contra baseline 6 (sem regressão). - `npx eslint --max-warnings 0` nos 9 arquivos de código que esta branch altera → **exit 0**, nenhum warning. Conteúdo: os dois commits do #12402 que não estavam subsumidos, com autoria do @ggiak preservada pelo cherry-pick `-x`, mais o rebaseline e o fragmento de changelog que faltava. `typecheck:core` limpo e **138/138** nos testes focados. As três versões (`claudeCodeClient.ts`, `Dockerfile`, `compose.yml`) conferem em 2.1.258, e `npm view @anthropic-ai/claude-code@2.1.258` resolve — o Dockerfile instala esse pin exato. --- .env.example | 2 +- .../maintenance/12402-claude-code-2-1-258.md | 1 + config/quality/file-size-baseline.json | 3 ++- docker/devin-bridge/Dockerfile | 2 +- docker/devin-bridge/compose.yml | 2 +- docs/DEVIN_CLAUDE_BRIDGE.md | 18 +++++++++----- docs/providers/AGENTROUTER.md | 2 +- docs/reference/ENVIRONMENT.md | 2 +- docs/security/STEALTH_GUIDE.md | 6 ++--- .../settings/components/RoutingTab.tsx | 3 ++- src/shared/constants/claudeCodeClient.ts | 6 ++--- tests/snapshots/provider/translate-path.json | 24 +++++++++---------- .../unit/anthropic-cache-fingerprint.test.ts | 2 +- tests/unit/cc-bridge-transforms.test.ts | 2 +- ...claude-codex-identity-version-sync.test.ts | 10 ++++---- tests/unit/client-identity-profiles.test.ts | 6 ++--- tests/unit/executor-default-base.test.ts | 4 ++-- tests/unit/glm-executor.test.ts | 8 +++---- tests/unit/system-transforms.test.ts | 2 +- 19 files changed, 57 insertions(+), 48 deletions(-) create mode 100644 changelog.d/maintenance/12402-claude-code-2-1-258.md diff --git a/.env.example b/.env.example index 81b03d5b11..9527957b3d 100644 --- a/.env.example +++ b/.env.example @@ -1290,7 +1290,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.219 (external, cli)" +CLAUDE_USER_AGENT="claude-cli/2.1.258 (external, cli)" # Disable the deterministic tool-name cloak applied on both Anthropic-bound paths # (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) — diff --git a/changelog.d/maintenance/12402-claude-code-2-1-258.md b/changelog.d/maintenance/12402-claude-code-2-1-258.md new file mode 100644 index 0000000000..6b5ba0b108 --- /dev/null +++ b/changelog.d/maintenance/12402-claude-code-2-1-258.md @@ -0,0 +1 @@ +- **chore(providers):** bump the Claude Code wire identity and the Devin bridge image pin from `2.1.220` to `2.1.258` ([#12402](https://github.com/diegosouzapw/OmniRoute/pull/12402)) — thanks @ggiak diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index b8415c5e8d..c39d3d5d41 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -439,7 +439,7 @@ "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201, "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1475, "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1271, - "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1606, + "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1607, "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1597, "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2152, "src/app/api/providers/[id]/models/route.ts": 2432, @@ -635,5 +635,6 @@ "_relax_velocity_2026_08_30": "127 frozen line caps and cap/testCap raised by 20% (velocity phase; see quality-baseline.json _policy).", "_rebaseline_2026_09_02_12325_merge_v3851": "Merge of release/v3.8.51 into #12325. Both sides grew chatCore.ts at the same chokepoint: #12239 took it 5946->5976 upstream, and this PR adds its +9 non-Codex 429 branch on top. check-file-size.mjs counts split(\"\\\\n\").length (trailing-newline empty element), so the merged file is 5981. The cap is the merged LOC, not either side alone; no other entry moves.", "_rebaseline_2026_09_03_houminxi_batch_stacked": "Crescimento medido DEPOIS que os 9 PRs da leva HouMinXi entraram, quando cada um empilhou sobre o rebaseline do anterior: providers/page.tsx 2007->2025 (+18 = feedback de erro por linha do import CSV do #12504 somado a busca por nome/baseUrl do #12495, ambos no mesmo painel de conexoes); chatCore.ts 5981->5984 (+3 = o #12325 invalida o cache generico de quota no 429 upstream, ao lado do ramo Codex ja existente); accountFallback.ts 2461->2467 (+6 = o #12566 empilha a carve-out de familia Antigravity sobre o rebaseline 2422->2461 que o #12590 registrou para o carve-out credits_exhausted da Moonshot; os dois tocam checkFallbackError). Cada PR mediu certo isoladamente, mas nenhum enxergava o empilhamento. Fiacao em chokepoints existentes. NAO cobre codex.ts nem stream.ts, que ja violavam no tip antes desta leva (drift da base).", + "_rebaseline_2026_09_03_12604_claude_code_2_1_258": "PR #12604 (bump da wire identity do Claude Code 2.1.220->2.1.258, commits do @ggiak vindos do #12402) crescimento proprio: src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx 1606->1607 (+1, a linha do seletor que acompanha a nova versao de identidade). Uma linha num painel de settings ja existente; nao ha o que extrair. Coberto por client-identity-profiles e claude-codex-identity-version-sync (138/138 focados).", "_rebaseline_2026_09_03_hartmark_batch": "Leva hartmark (#12293 #12355 #12447 #12445 #12446 #12460 #12461 #12338 #12448) crescimento proprio, medido no tip com os nove mergeados: src/app/(dashboard)/dashboard/combos/page.tsx 5012->5018 (+6, #12355 impede que a falha de bundling do tiktoken de um provider sem relacao derrube /api/providers, e o painel passa a lidar com o estado degradado); open-sse/services/combo.ts 4023->4036 (+13, #12338 nos fixes do universal-handoff: nota de bare-fallback, escopo por mesma requisicao e log da falha silenciosa). Fiacao em chokepoints existentes do roteamento de combo. NAO cobre codex.ts nem stream.ts, ja violando no tip antes desta leva (drift da base)." } diff --git a/docker/devin-bridge/Dockerfile b/docker/devin-bridge/Dockerfile index 3fd2a30dba..895455cf9f 100644 --- a/docker/devin-bridge/Dockerfile +++ b/docker/devin-bridge/Dockerfile @@ -1,6 +1,6 @@ FROM node:26.0.0-bookworm-slim -ARG CLAUDE_CODE_VERSION=2.1.220 +ARG CLAUDE_CODE_VERSION=2.1.258 ARG DEVIN_CLI_VERSION=3000.2.17 ARG TARGETARCH diff --git a/docker/devin-bridge/compose.yml b/docker/devin-bridge/compose.yml index c850414dbc..1a4ab8884b 100644 --- a/docker/devin-bridge/compose.yml +++ b/docker/devin-bridge/compose.yml @@ -28,7 +28,7 @@ x-runtime: &runtime context: ../.. dockerfile: docker/devin-bridge/Dockerfile args: - CLAUDE_CODE_VERSION: 2.1.220 + CLAUDE_CODE_VERSION: 2.1.258 DEVIN_CLI_VERSION: 3000.2.17 user: "10001:10001" read_only: true diff --git a/docs/DEVIN_CLAUDE_BRIDGE.md b/docs/DEVIN_CLAUDE_BRIDGE.md index 4a6d56d370..30b3bf3d4c 100644 --- a/docs/DEVIN_CLAUDE_BRIDGE.md +++ b/docs/DEVIN_CLAUDE_BRIDGE.md @@ -4,16 +4,22 @@ Messages endpoint while the official Devin CLI supplies model responses over ACP stdio. It does not modify the existing Anthropic, Claude OAuth, Claude Web, or `devin-cli` providers. -> **Current status: offline and live validated.** The pinned Claude Code `2.1.220` completed -> three isolated scenarios through Devin CLI `3000.2.17` and model -> `swe-1-7-lightning`. The final live run proved client-owned `Read`, `Edit`, and `Bash` -> turns, successful `npm test` results, project command and skill discovery, Devin-only -> routing, and zero Claude egress. +> **Current status: pinned Claude Code `2.1.258`; offline and live validation last recorded +> on `2.1.220`.** The `2.1.220` pin completed three isolated scenarios through Devin CLI +> `3000.2.17` and model `swe-1-7-lightning`; that final live run proved client-owned `Read`, +> `Edit`, and `Bash` turns, successful `npm test` results, project command and skill +> discovery, Devin-only routing, and zero Claude egress. The pin was then raised to `2.1.258` +> (the CLI generation OmniRoute's Claude identity impersonates, and the first line that ships +> the Fable 5.1 tier natively). On the new pin the install layer and `claude --version` were +> verified on the pinned base image, and the bridge unit suite, `compose config` and the +> static isolation proof pass — but the offline mock scenario and the live three-scenario +> suite have not been re-run yet. Re-run them (see "Updating pinned tools") before relying on +> the bridge with this pin. ## Architecture ```text -Claude Code 2.1.220 (isolated non-root Linux container) +Claude Code 2.1.258 (isolated non-root Linux container) -> http://omniroute:20128/v1/messages -> devin-cli-agentic (Claude-format, no-auth provider) -> devin acp --agent-type summarizer (official ACP stdio, no Devin tools) diff --git a/docs/providers/AGENTROUTER.md b/docs/providers/AGENTROUTER.md index aa28834c75..ea375f43c9 100644 --- a/docs/providers/AGENTROUTER.md +++ b/docs/providers/AGENTROUTER.md @@ -130,7 +130,7 @@ request (see `open-sse/services/claudeCodeCompatible.ts`): | Header | Value | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `Authorization` | `Bearer ` | -| `User-Agent` | `claude-cli/2.1.219 (external, sdk-cli)` | +| `User-Agent` | `claude-cli/2.1.258 (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 9dd2fff9f0..056a37beb9 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -622,7 +622,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | -------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.258 (external, cli)` | When Anthropic releases a new CLI version | | `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | Set to `1`/`true` to forward third-party harness tool names verbatim to Anthropic on both Anthropic-bound paths (native OAuth and CLIProxyAPI). By default the executor deterministically aliases non-Claude-Code tool names (Claude Code canonical mapping where one exists, otherwise PascalCase) and reverses them on the response via `_toolNameMap`, so harnesses with snake_case tools are not refused as fingerprinted third-party clients. Debugging only. | | `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | diff --git a/docs/security/STEALTH_GUIDE.md b/docs/security/STEALTH_GUIDE.md index 456a558b0c..767eb90a0b 100644 --- a/docs/security/STEALTH_GUIDE.md +++ b/docs/security/STEALTH_GUIDE.md @@ -117,8 +117,8 @@ 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.220 (external, sdk-cli)"` -- `CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.94.0"` +- `CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.258 (external, sdk-cli)"` +- `CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.112.1"` - `CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v26.3.0"` - `anthropic-beta = "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24"` by default - The per-connection "Enable redact-thinking beta" toggle adds `redact-thinking-2026-02-12` when a CC Compatible upstream specifically requires redacted thinking streams @@ -241,7 +241,7 @@ All MITM endpoints require management auth (`requireCliToolsAuth`). The sudo pas | Variable | Default | | ------------------------ | --------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.220 (external, cli)` | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.258 (external, cli)` | | `CODEX_USER_AGENT` | `codex-cli/0.149.0 (Windows 10.0.26200; x64)` | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` | | `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0` | diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index 5c844f80cb..f23b36ff5d 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -11,6 +11,7 @@ import { normalizeCliCompatProviderId, } from "@/shared/constants/cliCompatProviders"; import { AI_PROVIDERS } from "@/shared/constants/providers"; +import { CLAUDE_CODE_CLIENT_BUILD_REVISION } from "@/shared/constants/claudeCodeClient"; import { compareTr } from "@/shared/utils/turkishText"; import { HERMES } from "./systemTransformsHermesDefaults"; @@ -169,7 +170,7 @@ const DEFAULT_SYSTEM_TRANSFORMS_CLIENT = { entrypoint: "sdk-cli", versionFormat: "ex-machina", cchAlgo: "sha256-first-user", - buildRevision: "1f2", + buildRevision: CLAUDE_CODE_CLIENT_BUILD_REVISION, }, ], }, diff --git a/src/shared/constants/claudeCodeClient.ts b/src/shared/constants/claudeCodeClient.ts index 415068bc2d..dd72f6246e 100644 --- a/src/shared/constants/claudeCodeClient.ts +++ b/src/shared/constants/claudeCodeClient.ts @@ -4,10 +4,10 @@ * Keep this leaf dependency-free so server executors, compatibility bridges, * and client-facing identity presets can share one source of truth. */ -export const CLAUDE_CODE_CLIENT_VERSION = "2.1.220"; -export const CLAUDE_CODE_CLIENT_BUILD_REVISION = "1f2"; +export const CLAUDE_CODE_CLIENT_VERSION = "2.1.258"; +export const CLAUDE_CODE_CLIENT_BUILD_REVISION = "1e2"; export const CLAUDE_CODE_CLIENT_BILLING_VERSION = `${CLAUDE_CODE_CLIENT_VERSION}.${CLAUDE_CODE_CLIENT_BUILD_REVISION}`; -export const CLAUDE_CODE_SDK_PACKAGE_VERSION = "0.94.0"; +export const CLAUDE_CODE_SDK_PACKAGE_VERSION = "0.112.1"; export const CLAUDE_CODE_RUNTIME_VERSION = "v26.3.0"; export type ClaudeCodeEntrypoint = "cli" | "sdk-cli"; diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 63db4728b1..fbd4d1d22c 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -28,11 +28,11 @@ "apiKey": { "Accept": "text/event-stream", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.220 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.258 (external, sdk-cli)", "X-Stainless-Arch": "", "X-Stainless-Lang": "js", "X-Stainless-OS": "MacOS", - "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-Package-Version": "0.112.1", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v26.3.0", @@ -47,11 +47,11 @@ "nonStream": { "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.220 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.258 (external, sdk-cli)", "X-Stainless-Arch": "", "X-Stainless-Lang": "js", "X-Stainless-OS": "MacOS", - "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-Package-Version": "0.112.1", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v26.3.0", @@ -66,11 +66,11 @@ "oauth": { "Accept": "text/event-stream", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.220 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.258 (external, sdk-cli)", "X-Stainless-Arch": "", "X-Stainless-Lang": "js", "X-Stainless-OS": "MacOS", - "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-Package-Version": "0.112.1", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v26.3.0", @@ -989,13 +989,13 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.220 (external, cli)", + "User-Agent": "claude-cli/2.1.258 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "", "X-Stainless-Helper-Method": "stream", "X-Stainless-Lang": "js", "X-Stainless-Os": "", - "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-Package-Version": "0.112.1", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v26.3.0", @@ -1007,13 +1007,13 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.220 (external, cli)", + "User-Agent": "claude-cli/2.1.258 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "", "X-Stainless-Helper-Method": "stream", "X-Stainless-Lang": "js", "X-Stainless-Os": "", - "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-Package-Version": "0.112.1", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v26.3.0", @@ -1026,13 +1026,13 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.220 (external, cli)", + "User-Agent": "claude-cli/2.1.258 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "", "X-Stainless-Helper-Method": "stream", "X-Stainless-Lang": "js", "X-Stainless-Os": "", - "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-Package-Version": "0.112.1", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v26.3.0", diff --git a/tests/unit/anthropic-cache-fingerprint.test.ts b/tests/unit/anthropic-cache-fingerprint.test.ts index ebdb9690af..59c736a63c 100644 --- a/tests/unit/anthropic-cache-fingerprint.test.ts +++ b/tests/unit/anthropic-cache-fingerprint.test.ts @@ -5,6 +5,6 @@ import { CLAUDE_CODE_CLIENT_BILLING_VERSION } from "../../src/shared/constants/c describe("Anthropic billing header fingerprint (#1638)", () => { it("uses the immutable build revision captured from the signed CLI", () => { - assert.equal(CLAUDE_CODE_CLIENT_BILLING_VERSION, "2.1.220.1f2"); + assert.equal(CLAUDE_CODE_CLIENT_BILLING_VERSION, "2.1.258.1e2"); }); }); diff --git a/tests/unit/cc-bridge-transforms.test.ts b/tests/unit/cc-bridge-transforms.test.ts index ba915dcaca..cbaf19c50a 100644 --- a/tests/unit/cc-bridge-transforms.test.ts +++ b/tests/unit/cc-bridge-transforms.test.ts @@ -52,7 +52,7 @@ test("DEFAULT_CC_BRIDGE_PIPELINE places billing header at [0] and identity at [1 DEFAULT_CC_BRIDGE_PIPELINE ); const blocks = result.body.system as any[]; - assert.ok(blocks[0].text.startsWith("x-anthropic-billing-header: cc_version=2.1.220.1f2;")); + assert.ok(blocks[0].text.startsWith("x-anthropic-billing-header: cc_version=2.1.258.1e2;")); assert.equal(blocks[1].text, CLAUDE_AGENT_SDK_IDENTITY); }); diff --git a/tests/unit/claude-codex-identity-version-sync.test.ts b/tests/unit/claude-codex-identity-version-sync.test.ts index d274bb9f34..cda482fba9 100644 --- a/tests/unit/claude-codex-identity-version-sync.test.ts +++ b/tests/unit/claude-codex-identity-version-sync.test.ts @@ -38,11 +38,11 @@ test("Claude CLI version constants are in lockstep across all 4 sources", () => ); }); -test("Claude CLI wire versions match the captured 2.1.220 binary", () => { - assert.equal(canonical.CLAUDE_CODE_CLIENT_VERSION, "2.1.220"); - assert.equal(canonical.CLAUDE_CODE_CLIENT_BUILD_REVISION, "1f2"); - assert.equal(canonical.CLAUDE_CODE_CLIENT_BILLING_VERSION, "2.1.220.1f2"); - assert.equal(canonical.CLAUDE_CODE_SDK_PACKAGE_VERSION, "0.94.0"); +test("Claude CLI wire versions match the captured 2.1.258 binary", () => { + assert.equal(canonical.CLAUDE_CODE_CLIENT_VERSION, "2.1.258"); + assert.equal(canonical.CLAUDE_CODE_CLIENT_BUILD_REVISION, "1e2"); + assert.equal(canonical.CLAUDE_CODE_CLIENT_BILLING_VERSION, "2.1.258.1e2"); + assert.equal(canonical.CLAUDE_CODE_SDK_PACKAGE_VERSION, "0.112.1"); assert.equal(canonical.CLAUDE_CODE_RUNTIME_VERSION, "v26.3.0"); assert.equal( compat.CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION, diff --git a/tests/unit/client-identity-profiles.test.ts b/tests/unit/client-identity-profiles.test.ts index 54e00736a5..6c24c54eaa 100644 --- a/tests/unit/client-identity-profiles.test.ts +++ b/tests/unit/client-identity-profiles.test.ts @@ -39,7 +39,7 @@ test("getClientIdentityProfileHeaders: unknown profile id falls back to no heade test("getClientIdentityProfileHeaders: known CLI profiles expose their preset headers", () => { const claudeCli = getClientIdentityProfileHeaders("claude-cli"); - assert.equal(claudeCli["User-Agent"], "claude-cli/2.1.220 (external, cli)"); + assert.equal(claudeCli["User-Agent"], "claude-cli/2.1.258 (external, cli)"); assert.equal(claudeCli["X-App"], "cli"); const codexCli = getClientIdentityProfileHeaders("codex-cli"); @@ -55,7 +55,7 @@ test("getClientIdentityProfileHeaders: returns a fresh mutable copy (catalog sta headers["User-Agent"] = "tampered"; assert.equal( CLIENT_IDENTITY_PROFILES["claude-cli"].headers["User-Agent"], - "claude-cli/2.1.220 (external, cli)" + "claude-cli/2.1.258 (external, cli)" ); }); @@ -100,7 +100,7 @@ test("profile headers merged into customHeaders survive applyCustomHeaders sanit true ) as Record; - assert.equal(headers["User-Agent"], "claude-cli/2.1.220 (external, cli)"); + assert.equal(headers["User-Agent"], "claude-cli/2.1.258 (external, cli)"); assert.equal(headers["X-App"], "cli"); assert.equal(headers["Authorization"], "Bearer test-key"); }); diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index 0e21678054..469ffe992c 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -1571,12 +1571,12 @@ test("DefaultExecutor.execute does not produce duplicate anthropic-version heade assert.equal(versionKeys.length, 1, "Duplicate anthropic-version header keys found"); assert.equal(capturedHeaders[versionKeys[0]], "2023-06-01"); assert.equal(capturedHeaders["X-Stainless-Runtime-Version"], "v26.3.0"); - assert.equal(capturedHeaders["X-Stainless-Package-Version"], "0.94.0"); + assert.equal(capturedHeaders["X-Stainless-Package-Version"], "0.112.1"); const sentBody = JSON.parse(capturedBody) as { system?: Array<{ text?: string }> }; assert.match( sentBody.system?.[0]?.text ?? "", - /^x-anthropic-billing-header: cc_version=2\.1\.220\.1f2; cc_entrypoint=cli; cch=[0-9a-f]{5};$/ + /^x-anthropic-billing-header: cc_version=2\.1\.258\.1e2; cc_entrypoint=cli; cch=[0-9a-f]{5};$/ ); }); diff --git a/tests/unit/glm-executor.test.ts b/tests/unit/glm-executor.test.ts index 4cb31e90f6..26ef4f8526 100644 --- a/tests/unit/glm-executor.test.ts +++ b/tests/unit/glm-executor.test.ts @@ -130,9 +130,9 @@ test("GlmExecutor normalizes GLM coding and Anthropic URLs without duplicating e }); test("GlmExecutor separates OpenAI-compatible coding headers from Anthropic headers", async () => { - assert.equal(await getExecutor("glm") instanceof GlmExecutor, true); - assert.equal(await getExecutor("glm-cn") instanceof GlmExecutor, true); - assert.equal(await getExecutor("glmt") instanceof GlmExecutor, true); + assert.equal((await getExecutor("glm")) instanceof GlmExecutor, true); + assert.equal((await getExecutor("glm-cn")) instanceof GlmExecutor, true); + assert.equal((await getExecutor("glmt")) instanceof GlmExecutor, true); const executor = new GlmExecutor("glm"); const codingHeaders = executor.buildHeaders( @@ -183,7 +183,7 @@ test("GlmExecutor separates OpenAI-compatible coding headers from Anthropic head assert.equal(anthropicHeaders["anthropic-version"], "2023-06-01"); assert.match(anthropicHeaders["anthropic-beta"], /claude-code-20250219/); assert.equal(anthropicHeaders["anthropic-dangerous-direct-browser-access"], "true"); - assert.match(anthropicHeaders["User-Agent"], /^claude-cli\/2\.1\.220 \(external, sdk-cli\)$/); + assert.match(anthropicHeaders["User-Agent"], /^claude-cli\/2\.1\.258 \(external, sdk-cli\)$/); assert.equal(anthropicHeaders["X-Stainless-Lang"], "js"); assert.equal(anthropicHeaders["X-Stainless-Runtime"], "node"); }); diff --git a/tests/unit/system-transforms.test.ts b/tests/unit/system-transforms.test.ts index 047063d663..e5683766eb 100644 --- a/tests/unit/system-transforms.test.ts +++ b/tests/unit/system-transforms.test.ts @@ -559,7 +559,7 @@ const UI_DEFAULTS_SNAPSHOT = { entrypoint: "sdk-cli", versionFormat: "ex-machina", cchAlgo: "sha256-first-user", - buildRevision: "1f2", + buildRevision: "1e2", }, ], }, From 910f58c5cc664cccef25d79dc73d2230c474990d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 3 Sep 2026 17:26:52 -0300 Subject: [PATCH 47/47] docs(quality): document how the CodeQL ratchet refreshes and how to tighten it (#12611) Co-authored-by: Markus Hartung --- docs/architecture/QUALITY_GATES.md | 52 +++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index a3bca79c6b..4b4315cc4d 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -90,17 +90,17 @@ Runs on every PR to `main`. Blocks merge on failure. Runs after `test-coverage`. Blocks merge on failure. -| Script | Validates | Blocking | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | -| `quality:collect` | Emits `quality-metrics.json` (ESLint warning count, coverage from merged shard report) | Yes (upstream of ratchet) | -| `quality:ratchet` | Each metric in `quality-baseline.json` has not regressed (ESLint warnings ≤ baseline; coverage ≥ baseline) | Yes | -| `check:duplication` | Code duplication (jscpd@4) does not exceed baseline in `quality-baseline.json` | Yes | -| `check:complexity` | File-level cyclomatic complexity does not exceed the cap (core ESLint `complexity` + `max-lines-per-function`) | Yes | -| `check:cognitive-complexity` | Cognitive complexity ratchet (`eslint-plugin-sonarjs`) — separate ESLint pass; CI runs both merged as the single `check:complexity-ratchets` step | Yes | -| `check:dead-code` | Unused exports / files ratchet (knip) does not regress vs baseline | Yes | -| `check:compression-budget` | Compression benchmark budget — per-engine token-savings floors must not regress | Yes | -| `check:type-coverage` | Percent-typed ratchet (`type-coverage`) does not regress; largely subsumes `typecheck:noimplicit:core` | Yes | -| `check:codeql-ratchet` | Open CodeQL alert count does not regress (reads via `gh api`; graceful-skip without token) | Yes | +| Script | Validates | Blocking | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| `quality:collect` | Emits `quality-metrics.json` (ESLint warning count, coverage from merged shard report) | Yes (upstream of ratchet) | +| `quality:ratchet` | Each metric in `quality-baseline.json` has not regressed (ESLint warnings ≤ baseline; coverage ≥ baseline) | Yes | +| `check:duplication` | Code duplication (jscpd@4) does not exceed baseline in `quality-baseline.json` | Yes | +| `check:complexity` | File-level cyclomatic complexity does not exceed the cap (core ESLint `complexity` + `max-lines-per-function`) | Yes | +| `check:cognitive-complexity` | Cognitive complexity ratchet (`eslint-plugin-sonarjs`) — separate ESLint pass; CI runs both merged as the single `check:complexity-ratchets` step | Yes | +| `check:dead-code` | Unused exports / files ratchet (knip) does not regress vs baseline | Yes | +| `check:compression-budget` | Compression benchmark budget — per-engine token-savings floors must not regress | Yes | +| `check:type-coverage` | Percent-typed ratchet (`type-coverage`) does not regress; largely subsumes `typecheck:noimplicit:core` | Yes | +| `check:codeql-ratchet` | Open CodeQL alert count does not regress (reads via `gh api`; graceful-skip without token) — refresh cadence and manual trigger: see "CodeQL ratchet" below | Yes | ### Job: `quality-extended` @@ -324,6 +324,36 @@ Commit this file alongside the change that improved the metric. A PR that improv metric without updating the baseline will be caught by `--require-tighten` (Fase 6A.5, pending implementation). +### CodeQL ratchet: refresh cadence and manual trigger + +`check:codeql-ratchet` reads **repo state, refreshed on a schedule — not per PR.** +`gh api repos/diegosouzapw/OmniRoute/code-scanning/default-setup` reports +`state: configured`, `schedule: weekly`: GitHub's default-setup scan, not a per-push +analysis. Consequence: after a PR that FIXES alerts merges, the ratchet keeps reading +the old, higher count until the next scheduled scan runs — so it reports a regression +on every open PR, including the fixing PR's own follow-ups, until the scan catches up. + +**Manual refresh**: `gh workflow run codeql.yml --ref release/vX.Y.Z` re-runs the +analysis and republishes alerts within minutes. Read `.github/workflows/codeql.yml` +first — its header explains it is `workflow_dispatch`-only **because it conflicts with +GitHub's "default setup"** (`CodeQL analyses from advanced configurations cannot be +processed when the default setup is enabled`). Restoring `push`/`pull_request`/ +`schedule` triggers requires an **owner action first**: Settings → Code security → +CodeQL: Default → Advanced. Do not add a `schedule:` trigger without that switch — it +will only produce failing runs. + +**Tighten the baseline after the count drops** — `node scripts/check/check-codeql-ratchet.mjs +--update` writes the new measured count into `quality-baseline.json` → +`metrics.codeqlAlerts.value`, so the ratchet does not silently permit a regression back +up to the old ceiling. Worked example (2026-09-02/03): PR #12502 fixed 7 real alerts +(13 → 6 measured open); PR #12530 tightened the frozen baseline 11 → 6 to match; the +remaining 6 were then dismissed with per-alert justification down to 0 open. + +**Dismissals are the operator's call (Hard Rule #14)** — never dismiss a CodeQL alert +without recording the technical justification in the dismissal comment: `won't fix` for +an upstream-protocol requirement, `used in tests` for a test fixture, `false positive` +for a sanitizer CodeQL cannot see (precedent: `docs/security/ERROR_SANITIZATION.md`). + --- ## Test Retry Policy (WS5.4, v3.8.49)