From c0dcdcc12f99d98137aaa381959af1180fa3604a Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 8 Jun 2026 16:48:16 -0300 Subject: [PATCH 01/34] chore(release): open v3.8.17 development cycle --- CHANGELOG.md | 6 ++++++ docs/reference/openapi.yaml | 2 +- electron/package-lock.json | 4 ++-- electron/package.json | 2 +- open-sse/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 7 files changed, 15 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abcc6f1d396c..aeb8a4b00d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.17] — Unreleased + +_Development cycle in progress — entries are added as work merges into `release/v3.8.17` and finalized by the release flow._ + +--- + ## [3.8.16] — 2026-06-08 ### ✨ New Features diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 9509aaf78396..5c6b020add43 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.16 + version: 3.8.17 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/electron/package-lock.json b/electron/package-lock.json index 0a3a8675fb72..33a8b3056d46 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.16", + "version": "3.8.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.16", + "version": "3.8.17", "license": "MIT", "dependencies": { "electron-updater": "^6.8.8" diff --git a/electron/package.json b/electron/package.json index f5faa5de26eb..31c3274fe2ae 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.16", + "version": "3.8.17", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/package.json b/open-sse/package.json index 56d8dcf9f884..56a3eb94a617 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.16", + "version": "3.8.17", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index b475b3322299..88993909415c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.16", + "version": "3.8.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.16", + "version": "3.8.17", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -21467,7 +21467,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.16" + "version": "3.8.17" } } } diff --git a/package.json b/package.json index fc1a454e8cbb..5c361a75a1cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.16", + "version": "3.8.17", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { From eb8651780dcf0592c1fd76f0ef7e10404b54ddda Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:39:52 -0300 Subject: [PATCH 02/34] deps: bump electron from 42.3.2 to 42.3.3 in /electron (#3441) Integrated into release/v3.8.17 --- electron/package-lock.json | 203 +------------------------------------ electron/package.json | 2 +- 2 files changed, 5 insertions(+), 200 deletions(-) diff --git a/electron/package-lock.json b/electron/package-lock.json index 33a8b3056d46..e04ac8182f40 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -12,7 +12,7 @@ "electron-updater": "^6.8.8" }, "devDependencies": { - "electron": "^42.3.2", + "electron": "^42.3.3", "electron-builder": "^26.14.0" }, "engines": { @@ -294,45 +294,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1495,15 +1456,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1843,9 +1795,9 @@ } }, "node_modules/electron": { - "version": "42.3.2", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.3.2.tgz", - "integrity": "sha512-R8qL6TMQ+u5r/l1mL6zWqqK3eQo6Bqi4ScmysgODcy+SNR76HQhvbZUEL/9GqNuF0fNFJxphz/bh/TC4jW8NbA==", + "version": "42.3.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.3.3.tgz", + "integrity": "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1887,19 +1839,6 @@ "node": ">=14.0.0" } }, - "node_modules/electron-builder-squirrel-windows": { - "version": "26.14.0", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.14.0.tgz", - "integrity": "sha512-70u7EPxZXSIf1106/pwodH9UXtd/n+ZrWaCoQosUVXH5Q5fa5tbk5I9a6kup0lxoX7HyoFx7GptgNJnGqInXkw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "app-builder-lib": "26.14.0", - "builder-util": "26.14.0", - "electron-winstaller": "5.4.0" - } - }, "node_modules/electron-publish": { "version": "26.14.0", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.14.0.tgz", @@ -1934,66 +1873,6 @@ "tiny-typed-emitter": "^2.1.0" } }, - "node_modules/electron-winstaller": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", - "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@electron/asar": "^3.2.1", - "debug": "^4.1.1", - "fs-extra": "^7.0.1", - "lodash": "^4.17.21", - "temp": "^0.9.0" - }, - "engines": { - "node": ">=8.0.0" - }, - "optionalDependencies": { - "@electron/windows-sign": "^1.1.2" - } - }, - "node_modules/electron-winstaller/node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/electron-winstaller/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "peer": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-winstaller/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3236,20 +3115,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3588,36 +3453,6 @@ "node": ">=18" } }, - "node_modules/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/proc-log": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", @@ -3825,21 +3660,6 @@ "node": ">= 4" } }, - "node_modules/rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -4201,21 +4021,6 @@ "node": ">=18" } }, - "node_modules/temp": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", - "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mkdirp": "^0.5.1", - "rimraf": "~2.6.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/temp-file": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", diff --git a/electron/package.json b/electron/package.json index 31c3274fe2ae..27f701cb0ad4 100644 --- a/electron/package.json +++ b/electron/package.json @@ -28,7 +28,7 @@ "electron-updater": "^6.8.8" }, "devDependencies": { - "electron": "^42.3.2", + "electron": "^42.3.3", "electron-builder": "^26.14.0" }, "overrides": { From de2420a35c811dd854830882dde660a586cc4032 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:40:00 -0300 Subject: [PATCH 03/34] deps: bump the production group with 10 updates (#3444) Integrated into release/v3.8.17 --- package-lock.json | 329 ++++++++++++++++++++++++---------------------- package.json | 2 +- 2 files changed, 175 insertions(+), 156 deletions(-) diff --git a/package-lock.json b/package-lock.json index 88993909415c..342dff1d5f6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "selfsigned": "^5.5.0", "sql.js": "^1.14.1", "sqlite-vec": "^0.1.9", + "tls-client-node": "0.2.0", "tsx": "^4.22.3", "undici": "^8.3.0", "update-notifier": "^7.3.1", @@ -93,7 +94,7 @@ "@testing-library/react": "^16.3.2", "@types/bcryptjs": "^3.0.0", "@types/better-sqlite3": "^7.6.13", - "@types/bun": "latest", + "@types/bun": "*", "@types/keytar": "^4.4.2", "@types/node": "^25.9.1", "@types/react": "^19.2.15", @@ -124,7 +125,7 @@ "optionalDependencies": { "better-sqlite3": "^12.10.0", "keytar": "^7.9.0", - "tls-client-node": "^0.1.13", + "tls-client-node": "^0.2.0", "wreq-js": "^2.3.1" } }, @@ -320,20 +321,20 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1059.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1059.0.tgz", - "integrity": "sha512-8mU0exSWHKJhADHogSkeFYGB3GyqEuFPCXLLReEvrBOfEJKvSHfP7RDLOite96CV7LgL13NScI03mwCLDB+ynw==", + "version": "3.1063.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1063.0.tgz", + "integrity": "sha512-zTWEIvCFDJ13VjAyK8UouphesohVgZr3u7r6f74w8rtypPkci2vZtmttKxj/eeX2GQipuyHNgPwut2eXoL28aA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.16", - "@aws-sdk/credential-provider-node": "^3.972.49", - "@aws-sdk/eventstream-handler-node": "^3.972.19", - "@aws-sdk/middleware-eventstream": "^3.972.15", - "@aws-sdk/middleware-websocket": "^3.972.24", - "@aws-sdk/token-providers": "3.1059.0", - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/core": "^3.974.18", + "@aws-sdk/credential-provider-node": "^3.972.52", + "@aws-sdk/eventstream-handler-node": "^3.972.20", + "@aws-sdk/middleware-eventstream": "^3.972.16", + "@aws-sdk/middleware-websocket": "^3.972.26", + "@aws-sdk/token-providers": "3.1063.0", + "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", @@ -345,13 +346,13 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.16.tgz", - "integrity": "sha512-WXPvTfG7J2H4Ae6ewhd0285UC+8+9p/pKoibXXQlbXSqHexFLGM0oXHTwDfQEPmrNnvuWPpVjgoAUfW+cUFbXw==", + "version": "3.974.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.18.tgz", + "integrity": "sha512-JDYCPI0j7zGrzXTDFsLB346cxss7J/AxH7+O0MzWlqppJBEyB9Qe6TQXRL6iwLUo/xZkNv9KFmBL2hqElmwW0g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.10", - "@aws-sdk/xml-builder": "^3.972.27", + "@aws-sdk/types": "^3.973.11", + "@aws-sdk/xml-builder": "^3.972.28", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", @@ -364,13 +365,13 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.42.tgz", - "integrity": "sha512-0+MCOYqeHyADFdeVr5/e1G8JJoWm7/szZAiKssqJS84E9+tO07509YNyQRJ5a7x5wO9YFAV324syrwm6yIcs5w==", + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.44.tgz", + "integrity": "sha512-3hKJVrZ7bqXzDAXCQp+OaQ1ASN+vWstaNuEH418wQVl//cRZhqhfR9Bjk1qIWmgUGe8/D3gdO73PgidRj378EQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.16", - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/core": "^3.974.18", + "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -380,13 +381,13 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.44", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.44.tgz", - "integrity": "sha512-auuhqlnv4PUdfqcdHLKGTdoCceXOuby6WNeCMoxtZmQGWNCibbz95/lSYzNWq9cExN17UlRFqo1nvTcC+zHfEg==", + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.46.tgz", + "integrity": "sha512-VhwC9pGAZHhiQ2xSViyOPDFqvr9aRxGCAXZtADsUhU3R65nad7y//CwynE6mQnWNR+suRlqE79W36IVayL+m1g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.16", - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/core": "^3.974.18", + "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", @@ -398,20 +399,20 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.47", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.47.tgz", - "integrity": "sha512-G+8JuG0CfLcC2IQXFVqMR1+KDF1rksebr+YL6+HHYbNjs/hiwX53ye45sU3pJKljpS2uIXqOrOsicHIv02vWrw==", + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.50.tgz", + "integrity": "sha512-09Xi6ovxiK42+De/qBGF71sT5F2bWgYM+1fFyDwSOpy1xpsQ5R/naIu7MVDpH6Dic36QNc8dAv4KADtMGK2JYg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.16", - "@aws-sdk/credential-provider-env": "^3.972.42", - "@aws-sdk/credential-provider-http": "^3.972.44", - "@aws-sdk/credential-provider-login": "^3.972.46", - "@aws-sdk/credential-provider-process": "^3.972.42", - "@aws-sdk/credential-provider-sso": "^3.972.46", - "@aws-sdk/credential-provider-web-identity": "^3.972.46", - "@aws-sdk/nested-clients": "^3.997.14", - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/core": "^3.974.18", + "@aws-sdk/credential-provider-env": "^3.972.44", + "@aws-sdk/credential-provider-http": "^3.972.46", + "@aws-sdk/credential-provider-login": "^3.972.49", + "@aws-sdk/credential-provider-process": "^3.972.44", + "@aws-sdk/credential-provider-sso": "^3.972.49", + "@aws-sdk/credential-provider-web-identity": "^3.972.49", + "@aws-sdk/nested-clients": "^3.997.17", + "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", @@ -422,14 +423,14 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.46.tgz", - "integrity": "sha512-+H6h2/1Q+iIJ4w+FPCKM//xwy4C9yADknDTR68+K3PbOtB/uLup3zIH8PUKn6QwnttME4VM4ftSTnbvoDf5wXg==", + "version": "3.972.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.49.tgz", + "integrity": "sha512-EfJF/1Fh9mI4pZyoheU2RY9xUhTcugIZNkD63+orXMkYj/QXacJNbKVDUK90Yv5hE+aX+rt9J/EZ9Qr3vKOa7g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.16", - "@aws-sdk/nested-clients": "^3.997.14", - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/core": "^3.974.18", + "@aws-sdk/nested-clients": "^3.997.17", + "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -439,18 +440,18 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.49", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.49.tgz", - "integrity": "sha512-hlyoc+2352BhC+HF91t9avIDJu1+EvQGGltxFB8ADCpAHGYNNLEQAZJIPzw3O/bRiiDSE2B8pdj/fFCXlDTEDg==", + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.52.tgz", + "integrity": "sha512-7QX+PbyiWBEOVipJq8Nke/TqXT6lAPLE7fvTaopa39/IVWuLfS+Fzdy71sZJONf/mLGgmtj6aU17+REw3+aRrw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.42", - "@aws-sdk/credential-provider-http": "^3.972.44", - "@aws-sdk/credential-provider-ini": "^3.972.47", - "@aws-sdk/credential-provider-process": "^3.972.42", - "@aws-sdk/credential-provider-sso": "^3.972.46", - "@aws-sdk/credential-provider-web-identity": "^3.972.46", - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/credential-provider-env": "^3.972.44", + "@aws-sdk/credential-provider-http": "^3.972.46", + "@aws-sdk/credential-provider-ini": "^3.972.50", + "@aws-sdk/credential-provider-process": "^3.972.44", + "@aws-sdk/credential-provider-sso": "^3.972.49", + "@aws-sdk/credential-provider-web-identity": "^3.972.49", + "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", @@ -461,13 +462,13 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.42.tgz", - "integrity": "sha512-r996IYVtQ7rWa5UfSs3fZLT/Dq/SSgH9wv9zahx9lcg6vvaPPQHFpTy45nZajZu/+OUdEQxEjTn9rOMons59mA==", + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.44.tgz", + "integrity": "sha512-V+UUhZpRP7QDRhi+qgBDisM9tUBnYmMje8Bk77A6MZsfeGeGdMsQXmaHP1CDYFcept0o/Rz5g2Y0TMeVlG9dzg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.16", - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/core": "^3.974.18", + "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -477,15 +478,15 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.46.tgz", - "integrity": "sha512-vd+UqRbiYLvXXH3kTAuTxq+Vdb3rOgg29/FeB35ETsgdJTTB7x3YuqtpRckATsL5bDRXFVQo0uBj0/vxCJ8hHg==", + "version": "3.972.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.49.tgz", + "integrity": "sha512-9QqOYGuh5tZ76OzaT68kwI78AH+5lS/uZGGvkfxb3fc8FzRrIz2jOufNTliEBEeSAwmgK2rWLNsK+IB3zbtNPA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.16", - "@aws-sdk/nested-clients": "^3.997.14", - "@aws-sdk/token-providers": "3.1059.0", - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/core": "^3.974.18", + "@aws-sdk/nested-clients": "^3.997.17", + "@aws-sdk/token-providers": "3.1063.0", + "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -495,14 +496,14 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.46.tgz", - "integrity": "sha512-xuxBbMorygYsbDV6E/tUGQUgIDfhnzxz8uJYX8rByzehI6gLUu8RPsgOpLmh9YWerqeHZxJbqzgheBSB7tpooQ==", + "version": "3.972.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.49.tgz", + "integrity": "sha512-IYx1lN38MnnPXv+NBLpuATu0cZakbZ321TAfjW+aVkw7HIJF38YnEwdeEO55MSl3pl7hIX1IvvnD6EmnAzmAJw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.16", - "@aws-sdk/nested-clients": "^3.997.14", - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/core": "^3.974.18", + "@aws-sdk/nested-clients": "^3.997.17", + "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -512,12 +513,12 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.19.tgz", - "integrity": "sha512-MZhrsChY4jwEp7LQnNkcNSvF4KHjDC8es1pgu61h6L48fY7YgRqDfGRoT4ADd7lj4dB+gtOYITgmf7k4QQ2TKg==", + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.20.tgz", + "integrity": "sha512-qr/S1iFCDIXlZwlZPaCqjKcHbJFr9scIFUhbh2+SrwPXZvRhyOUWjVDJpp8xoU4qrrMR0PqK1Yw5C2sSj7xAyw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -527,12 +528,12 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.15.tgz", - "integrity": "sha512-4qYsO6temM6rEawcxHpMPWnRSIiLzsKhuizMlXCVujj54Q+HoGkVlcxk8S+5ekq/hOBdkyRnQjNsZaeRBz60hg==", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.16.tgz", + "integrity": "sha512-KR2Gdui/QLbkdG9FxW3vk/vIa8KiDP5vQBNERo7MmlPHjn23GXJ53Cq5P/ok7/ALbTUiYZ78DiBHoDcvzPWvgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -542,13 +543,13 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.24.tgz", - "integrity": "sha512-HIrW6vjDkdeh6cGlYxf9jLYy+NW2UhT0iWT7m7wf8sUNORZ37uExKGCSLvXIGYTdDIiM7a8eIwRUyC9GAkX/ZQ==", + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.26.tgz", + "integrity": "sha512-foM3KvxGBHY9lRIm6C9JJJ5haodtXfJPPgJQcv5/c4A2pN4I7tlnOjh1o2d8Il1Y/j6GWOw3YeIYc2/VYjtGVQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.16", - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/core": "^3.974.18", + "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/signature-v4": "^5.4.6", @@ -560,16 +561,16 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.14.tgz", - "integrity": "sha512-T5CS1r4P27FjkBYIWwVibWqEuq32BbCga2Z5m5OBSdSdi2wPfW2vl6zLWAB/5MeeyC4s2pY/MY3cj2Gd3rgSkg==", + "version": "3.997.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.17.tgz", + "integrity": "sha512-lDRgraoTfKRawUyc176Ow93mrNrOho/x+EoK4C+lKU+vKkHWhNhzvSMVAx0WEJUJoeQxxDN5ZdKMfiGEyNejig==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.16", - "@aws-sdk/signature-v4-multi-region": "^3.996.31", - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/core": "^3.974.18", + "@aws-sdk/signature-v4-multi-region": "^3.996.32", + "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", @@ -581,12 +582,12 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.31.tgz", - "integrity": "sha512-Kn2up9SlG1KC6wRtwf0d7waTGF6rvp9DxYqB54x6UCKdQ6kyaXCqHL4WGb5vUJga5kS8FxnjhY0LqM28aMvnNQ==", + "version": "3.996.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.32.tgz", + "integrity": "sha512-llvApLcsWtmRFhG2wT3WIp1CmDeRaIYutqty1ZZXoMzK7TiJ6MOLOimk9eXUS8PwgG4ew4pa4QAbt0lfhn++1w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/types": "^3.973.11", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -596,14 +597,14 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1059.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1059.0.tgz", - "integrity": "sha512-xql+7YBAE7WYb9xJfY0vcAXM8rJXfClmB2wkt+g/EoLUMog0pOb7o741fE96wFqha0uQTEgTo/5lGGguzavxmw==", + "version": "3.1063.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1063.0.tgz", + "integrity": "sha512-nYDaWWdzjKiDP5xj8k4oUgcYd4WPgzfAOgdU5vJsaqH/07Dfvm7ffisHCFJ+NEl7kUC9JEIUxh0kznvenbo3NQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.16", - "@aws-sdk/nested-clients": "^3.997.14", - "@aws-sdk/types": "^3.973.10", + "@aws-sdk/core": "^3.974.18", + "@aws-sdk/nested-clients": "^3.997.17", + "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -613,9 +614,9 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.10.tgz", - "integrity": "sha512-992QrTO7G9qCvKD0fx1rMlqcL14plUcRAbwmqqYVsuF3GrqcvlAL9qxR+baMafarEZ+l7DUQ5lCMmt5mbMhF7g==", + "version": "3.973.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.11.tgz", + "integrity": "sha512-YjS0qFuECClRh4qhEyW8XagW0fwEPBeZ1cfsW/gU73Kh/ExFILxbzxOfPCmzF/2DwEvhvsHYt0b0qnvStwKYrg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.3", @@ -626,9 +627,9 @@ } }, "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "version": "3.965.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.6.tgz", + "integrity": "sha512-ZfHjfwSzeXj+Lg9AK5ZNmeDkXev6V+w2tn1t4kgDdRtUaRCthepTQiFwbD06EF9oNGH4LaLg+Mb6U16Ypv5bSw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -638,9 +639,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.27.tgz", - "integrity": "sha512-hpsCXCOI436kxWpjtRuIHVvuPP81MOw8f18jzfZeg+UOiiOvlqWcmWChzEhJEu16cOC6+ku4ncBN+7rdt+DZ9g==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.28.tgz", + "integrity": "sha512-lI/l3c/vPvsxmspzV63NfS3x9q4CkMmdhJy4QiM+NThAufVkDvi/PZZQ6xETnICL0UD7jI808pY83gllf86RFg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.3", @@ -5176,9 +5177,9 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.7.tgz", - "integrity": "sha512-xj8gq/bjFABAh6qWPSDCYcY3kzQIm4b561C+YnHH4zGq8rOgzQ3Shk+JGlpUxSd41UGiO6FkLdUCtNX1FAeHgg==", + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.8.tgz", + "integrity": "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg==", "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.24.6", @@ -5216,9 +5217,9 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.7.6", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.6.tgz", - "integrity": "sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ==", + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.7.tgz", + "integrity": "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A==", "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.24.6", @@ -6269,9 +6270,9 @@ } }, "node_modules/@types/mdx": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", - "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz", + "integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==", "license": "MIT" }, "node_modules/@types/ms": { @@ -7642,9 +7643,9 @@ } }, "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -11558,9 +11559,9 @@ } }, "node_modules/fumadocs-mdx": { - "version": "15.0.10", - "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.0.10.tgz", - "integrity": "sha512-kH3S7ESS9yXTAaCkA8dDugsCK/MbnpgyZ5qBEL7cWoavV0O/T4+4YTYFkvNknz7cw+T/r+OG0p2BvlVhkk4fww==", + "version": "15.0.11", + "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.0.11.tgz", + "integrity": "sha512-XDym6obv+VVqA+MUDpaqgmTuTarrwsvo+5F5erMZQQcSqki9W7CFvqlleKOYBsUdOuXh9B3ZW3QFirdTwNpAeQ==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.1.1", @@ -11568,12 +11569,12 @@ "chokidar": "^5.0.0", "esbuild": "^0.28.0", "estree-util-value-to-estree": "^3.5.0", - "js-yaml": "^4.1.1", + "js-yaml": "^4.2.0", "mdast-util-mdx": "^3.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.4", - "tinyexec": "^1.2.2", - "tinyglobby": "^0.2.16", + "tinyexec": "^1.2.4", + "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", @@ -12478,13 +12479,13 @@ } }, "node_modules/http-proxy-middleware": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-4.0.0.tgz", - "integrity": "sha512-wuHwaUtmC0XzJNHqRp41zXtt5ojpHbusXGhq6781VvnjWUYPu7opmOF3eomGNujT07kEOnHWZyV9UZzKimVCKA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-4.1.0.tgz", + "integrity": "sha512-XUOAjKncPZjsrgDTTem+CUED6A+piUAKTOwBM8g1gAublBX/GdxTHP8mO+og1EW1evYP8wd0G2c111tExwo/jw==", "license": "MIT", "dependencies": { "debug": "^4.4.3", - "httpxy": "^0.5.1", + "httpxy": "^0.5.3", "is-glob": "^4.0.3", "is-plain-obj": "^4.1.0", "micromatch": "^4.0.8" @@ -12494,22 +12495,23 @@ } }, "node_modules/https-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", - "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", "license": "MIT", "dependencies": { "agent-base": "9.0.0", - "debug": "^4.3.4" + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { "node": ">= 20" } }, "node_modules/httpxy": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/httpxy/-/httpxy-0.5.1.tgz", - "integrity": "sha512-JPhqYiixe1A1I+MXDewWDZqeudBGU8Q9jCHYN8ML+779RQzLjTi78HBvWz4jMxUD6h2/vUL12g4q/mFM0OUw1A==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/httpxy/-/httpxy-0.5.3.tgz", + "integrity": "sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==", "license": "MIT" }, "node_modules/husky": { @@ -12978,9 +12980,9 @@ "license": "MIT" }, "node_modules/ioredis": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.0.tgz", - "integrity": "sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==", + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", "license": "MIT", "dependencies": { "@ioredis/commands": "1.10.0", @@ -14702,9 +14704,9 @@ } }, "node_modules/marked": { - "version": "18.0.4", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", - "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -17359,6 +17361,23 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -19761,9 +19780,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -19807,9 +19826,9 @@ "license": "MIT" }, "node_modules/tls-client-node": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/tls-client-node/-/tls-client-node-0.1.13.tgz", - "integrity": "sha512-0jy0epw05aPiprbaPe+/ClsT6FB0bR1eS3u2uBPLQ3RWrqoYAv4ql2pSHm3BTcygxWurYdSrmXU4ggV67sBe5g==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/tls-client-node/-/tls-client-node-0.2.0.tgz", + "integrity": "sha512-0PHJgaGPvMK9ly7xohviOoe8Oxos43IOIdsEhibgku4ce/3/YLhxJTPPKNQZII0PdcOjlfPweB9eRs13mWaWIg==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "optional": true, @@ -20200,9 +20219,9 @@ } }, "node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.4.1.tgz", + "integrity": "sha512-RNHlB4fxZK0IrkhBsxhlbx7s8kFWwr7rzzOqj5nvZugw3ig3RsB7KW3zVlV0eu8POl+rx5d1hmL7rRg0z1owow==", "license": "MIT", "engines": { "node": ">=22.19.0" diff --git a/package.json b/package.json index 5c361a75a1cc..24b44db87d57 100644 --- a/package.json +++ b/package.json @@ -211,7 +211,7 @@ "optionalDependencies": { "better-sqlite3": "^12.10.0", "keytar": "^7.9.0", - "tls-client-node": "^0.1.13", + "tls-client-node": "^0.2.0", "wreq-js": "^2.3.1" }, "devDependencies": { From f01a0b0c6d352a7bdebeb2423a381ce3bb2d6917 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:40:04 -0300 Subject: [PATCH 04/34] deps: bump the development group with 4 updates (#3445) Integrated into release/v3.8.17 --- package-lock.json | 284 +++++++++++++++++++++++++++++++--------------- package.json | 2 +- 2 files changed, 194 insertions(+), 92 deletions(-) diff --git a/package-lock.json b/package-lock.json index 342dff1d5f6d..59a10d6445c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -102,7 +102,7 @@ "@types/ws": "^8.18.0", "@vitejs/plugin-react": "^6.0.2", "c8": "^11.0.0", - "concurrently": "^9.2.1", + "concurrently": "^10.0.3", "cross-env": "^10.1.0", "eslint": "^9.39.4", "eslint-config-next": "16.2.7", @@ -6282,9 +6282,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "25.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", + "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -6297,9 +6297,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.16", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", - "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", "dependencies": { @@ -6346,17 +6346,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", - "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", + "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/type-utils": "8.60.1", - "@typescript-eslint/utils": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -6369,7 +6369,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.60.1", + "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -6385,16 +6385,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", - "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", + "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3" }, "engines": { @@ -6410,14 +6410,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", - "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", + "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.1", - "@typescript-eslint/types": "^8.60.1", + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "engines": { @@ -6432,14 +6432,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", - "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", + "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1" + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6450,9 +6450,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", - "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", + "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", "dev": true, "license": "MIT", "engines": { @@ -6467,15 +6467,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", - "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", + "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -6492,9 +6492,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", - "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", + "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", "dev": true, "license": "MIT", "engines": { @@ -6506,16 +6506,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", - "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", + "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.1", - "@typescript-eslint/tsconfig-utils": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -6573,9 +6573,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", + "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", "dev": true, "license": "ISC", "bin": { @@ -6586,16 +6586,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", - "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", + "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1" + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6610,13 +6610,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", - "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", + "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -8765,30 +8765,135 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "4.1.2", + "chalk": "5.6.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", "tree-kill": "1.2.2", - "yargs": "17.7.2" + "yargs": "18.0.0" }, "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" }, "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/concurrently/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concurrently/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/concurrently/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -18741,9 +18846,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, "license": "MIT", "engines": { @@ -19523,16 +19628,13 @@ "license": "MIT" }, "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/supports-color?sponsor=1" @@ -20170,16 +20272,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", - "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", + "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.60.1", - "@typescript-eslint/parser": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/utils": "8.60.1" + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/package.json b/package.json index 24b44db87d57..bfd9fece0311 100644 --- a/package.json +++ b/package.json @@ -229,7 +229,7 @@ "@types/ws": "^8.18.0", "@vitejs/plugin-react": "^6.0.2", "c8": "^11.0.0", - "concurrently": "^9.2.1", + "concurrently": "^10.0.3", "cross-env": "^10.1.0", "eslint": "^9.39.4", "eslint-config-next": "16.2.7", From d3ff0b3bdeb54d0a2a3c6ecae0436a4705a4c8e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:40:27 -0300 Subject: [PATCH 05/34] deps: bump electron-updater from 6.8.8 to 6.8.9 in /electron (#3442) Integrated into release/v3.8.17 --- electron/package-lock.json | 11 ++++++----- electron/package.json | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/electron/package-lock.json b/electron/package-lock.json index e04ac8182f40..c42c5f5346c7 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -9,7 +9,7 @@ "version": "3.8.17", "license": "MIT", "dependencies": { - "electron-updater": "^6.8.8" + "electron-updater": "^6.8.9" }, "devDependencies": { "electron": "^42.3.3", @@ -1127,6 +1127,7 @@ "version": "9.6.3", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.6.3.tgz", "integrity": "sha512-W/bhEWeAetNaSyuCG6rpbd8cd3wp+ifpwOtso1tSkJCJrcHPlmlDdkqFHj3zRAzgvyJcDp/5fWRicSQbg23jdA==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -1858,12 +1859,12 @@ } }, "node_modules/electron-updater": { - "version": "6.8.8", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.8.tgz", - "integrity": "sha512-KUQeRNeRQo7KUhuuj29KCVodSVl5NF4whQn3QmXi1+L3vI9hMsgoIsmVYT1SXR5pCTD8eCCyeQboRoYP5Q7rCw==", + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", "license": "MIT", "dependencies": { - "builder-util-runtime": "9.6.3", + "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", diff --git a/electron/package.json b/electron/package.json index 27f701cb0ad4..06bb44947622 100644 --- a/electron/package.json +++ b/electron/package.json @@ -25,7 +25,7 @@ "pack": "npm run prepare:bundle && electron-builder --dir" }, "dependencies": { - "electron-updater": "^6.8.8" + "electron-updater": "^6.8.9" }, "devDependencies": { "electron": "^42.3.3", From 70c6610fa831736620f1d99af612eb021e176c92 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:40:58 -0300 Subject: [PATCH 06/34] deps: bump electron-builder from 26.14.0 to 26.15.2 in /electron (#3443) Integrated into release/v3.8.17 --- electron/package-lock.json | 1724 +++++++++++------------------------- 1 file changed, 502 insertions(+), 1222 deletions(-) diff --git a/electron/package-lock.json b/electron/package-lock.json index c42c5f5346c7..e61558c05516 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -199,34 +199,6 @@ "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/@electron/rebuild": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.3.tgz", - "integrity": "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.1.1", - "detect-libc": "^2.0.1", - "got": "^11.7.0", - "graceful-fs": "^4.2.11", - "node-abi": "^4.2.0", - "node-api-version": "^0.2.1", - "node-gyp": "^11.2.0", - "ora": "^5.1.0", - "read-binary-file-arch": "^1.0.6", - "semver": "^7.3.5", - "tar": "^7.5.6", - "yargs": "^17.0.1" - }, - "bin": { - "electron-rebuild": "lib/cli.js" - }, - "engines": { - "node": ">=22.12.0" - } - }, "node_modules/@electron/universal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", @@ -478,43 +450,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@npmcli/fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/@peculiar/asn1-schema": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", @@ -567,17 +502,6 @@ "node": ">=14.18.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -702,23 +626,6 @@ "node": ">=14.6" } }, - "node_modules/7zip-bin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", - "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", - "dev": true, - "license": "MIT" - }, - "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -772,153 +679,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/app-builder-bin": { - "version": "5.0.0-alpha.12", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", - "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/app-builder-lib": { - "version": "26.14.0", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.14.0.tgz", - "integrity": "sha512-eRW38gaj9uadOOKBaiRxxWe4b/yUtPlSTDLUpoGAiUSxfDSoMujUqijIkJzYC9Ag7/eeAwguYhNgtIbRtFDiIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@electron/asar": "3.4.1", - "@electron/fuses": "^1.8.0", - "@electron/get": "^3.0.0", - "@electron/notarize": "2.5.0", - "@electron/osx-sign": "1.3.3", - "@electron/rebuild": "4.0.3", - "@electron/universal": "2.0.3", - "@malept/flatpak-bundler": "^0.4.0", - "@noble/hashes": "^2.2.0", - "@peculiar/webcrypto": "^1.7.1", - "@types/fs-extra": "9.0.13", - "ajv": "^8.18.0", - "asn1js": "^3.0.10", - "async-exit-hook": "^2.0.1", - "builder-util": "26.14.0", - "builder-util-runtime": "9.6.3", - "chromium-pickle-js": "^0.2.0", - "ci-info": "4.3.1", - "debug": "^4.3.4", - "dotenv": "^16.4.5", - "dotenv-expand": "^11.0.6", - "ejs": "^3.1.8", - "electron-publish": "26.14.0", - "fs-extra": "^10.1.0", - "hosted-git-info": "^4.1.0", - "isbinaryfile": "^5.0.0", - "jiti": "^2.4.2", - "js-yaml": "^4.1.0", - "json5": "^2.2.3", - "lazy-val": "^1.0.5", - "minimatch": "^10.2.5", - "pkijs": "^3.4.0", - "plist": "3.1.0", - "proper-lockfile": "^4.1.2", - "resedit": "^1.7.0", - "semver": "~7.7.3", - "tar": "^7.5.7", - "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0", - "unzipper": "^0.12.3", - "which": "^5.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "dmg-builder": "26.14.0", - "electron-builder-squirrel-windows": "26.14.0" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", - "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=14" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/app-builder-lib/node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/app-builder-lib/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/app-builder-lib/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -991,39 +751,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -1053,31 +780,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -1095,34 +797,6 @@ "dev": true, "license": "MIT" }, - "node_modules/builder-util": { - "version": "26.14.0", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.14.0.tgz", - "integrity": "sha512-VG0MjpQgIzmkbQs3CIg0mxlTUyKmUEr+JHgS6IjV5LPh4z4CwLW1s9NBWW3Ak6xmR17pHBdpn7miPAcHCcFq3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.6", - "7zip-bin": "~5.2.0", - "app-builder-bin": "5.0.0-alpha.12", - "builder-util-runtime": "9.6.3", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.6", - "debug": "^4.3.4", - "fs-extra": "^10.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "js-yaml": "^4.1.0", - "sanitize-filename": "^1.6.3", - "source-map-support": "^0.5.19", - "stat-mode": "^1.0.0", - "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/builder-util-runtime": { "version": "9.6.3", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.6.3.tgz", @@ -1147,100 +821,14 @@ "node": ">=6.0.0" } }, - "node_modules/cacache": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, + "license": "MIT", "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/cacache/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/cacache/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/cacache/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.6.0" + "node": ">=10.6.0" } }, "node_modules/cacheable-request": { @@ -1310,48 +898,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -1367,16 +913,6 @@ "node": ">=12" } }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/clone-response": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", @@ -1541,19 +1077,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -1612,16 +1135,6 @@ "node": ">=0.4.0" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -1673,18 +1186,334 @@ } }, "node_modules/dmg-builder": { - "version": "26.14.0", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.14.0.tgz", - "integrity": "sha512-2MmJniyT5STSnedsuKR6YX4QKQIn8p6igK75hW1R8zl4bXGfNcP2DZwO2Mb5AwAsPsisKDcBR3pd0s7DXnT9qQ==", + "version": "26.15.2", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.2.tgz", + "integrity": "sha512-fMkjRqKyPtsz4Kzu/qGP0BGjqzMCIgp+/7kw/u6YH6lvn/8hvL3c0TXhoFayBoYdpPCnEinnCHztd4bW7/jetA==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.14.0", - "builder-util": "26.14.0", + "app-builder-lib": "26.15.2", + "builder-util": "26.15.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, + "node_modules/dmg-builder/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/dmg-builder/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/dmg-builder/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/dmg-builder/node_modules/@electron/rebuild": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz", + "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/dmg-builder/node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/dmg-builder/node_modules/app-builder-lib": { + "version": "26.15.2", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.2.tgz", + "integrity": "sha512-3mYfKOjr/ZY7gFESOcq8kylBMgGPpmlQYnpBVit4p6zIg0t/8bkWBILdMMtnjFyN2jllyBf225T8dLlz3D6oBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.0", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.1", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.2", + "electron-builder-squirrel-windows": "26.15.2" + } + }, + "node_modules/dmg-builder/node_modules/builder-util": { + "version": "26.15.0", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.0.tgz", + "integrity": "sha512-dUx+HxVbiNsNQ4mGe1PyoC/tBmsHwBNDLdBuqWCj+rhHFE9lHgrXiGYKAM1uNlznhAaUSyMlms84VeSSr3gOBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/dmg-builder/node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/dmg-builder/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dmg-builder/node_modules/electron-publish": { + "version": "26.15.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.1.tgz", + "integrity": "sha512-BMgMHOyexWn0UnOC+Afffw0DMrr0yfLp4U8YsLXwoJ3Da7LS7WUnz21teYZqO0gaApE1KgsjREWmbPqvF5JcPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.0", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/dmg-builder/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/dmg-builder/node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/dmg-builder/node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/dmg-builder/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/dmg-builder/node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1772,13 +1601,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -1786,76 +1608,183 @@ "dev": true, "license": "Apache-2.0", "dependencies": { - "jake": "^10.8.5" + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "42.3.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.3.3.tgz", + "integrity": "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.15.2", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.2.tgz", + "integrity": "sha512-veKM9+dCljaC5A74Pwc0ZWQ9arOHREXWh9hUIf8NGg49ch7x+IB4QhbMzIrV5ONZIXM2OEkaxW11cAPjPtoi4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.2", + "builder-util": "26.15.0", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-publish": { + "version": "26.14.0", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.14.0.tgz", + "integrity": "sha512-dw0zAn6j5yxILnCnmpAl5khHX41TagDfI8Hl+KSeqdvAu2/87rl5zou7R3/U56Q0Rws5oD/BBbhlQ9yt3JcZ/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.0", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-builder/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/electron-builder/node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" }, "bin": { - "ejs": "bin/cli.js" + "node-which": "bin/which.js" }, "engines": { - "node": ">=0.10.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/electron": { - "version": "42.3.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.3.3.tgz", - "integrity": "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ==", + "node_modules/electron-builder/node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@electron/get": "^5.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" + "abbrev": "^4.0.0" }, "bin": { - "electron": "cli.js", - "install-electron": "install.js" + "nopt": "bin/nopt.js" }, "engines": { - "node": ">= 22.12.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/electron-builder": { - "version": "26.14.0", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.14.0.tgz", - "integrity": "sha512-C/uyWCbfgETn3efNHuQce8S23D/9GqFfsEYgP8Zy/5+Fo5w6HZ78FcEdKRIQ7YT+uoHROEQNwrg/+dkd0fCpqw==", + "node_modules/electron-builder/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/electron-builder/node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", "dev": true, "license": "MIT", - "dependencies": { - "app-builder-lib": "26.14.0", - "builder-util": "26.14.0", - "builder-util-runtime": "9.6.3", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "dmg-builder": "26.14.0", - "fs-extra": "^10.1.0", - "lazy-val": "^1.0.5", - "simple-update-notifier": "2.0.0", - "yargs": "^17.6.2" - }, - "bin": { - "electron-builder": "cli.js", - "install-app-deps": "install-app-deps.js" - }, "engines": { - "node": ">=14.0.0" + "node": ">=18.17" } }, - "node_modules/electron-publish": { - "version": "26.14.0", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.14.0.tgz", - "integrity": "sha512-dw0zAn6j5yxILnCnmpAl5khHX41TagDfI8Hl+KSeqdvAu2/87rl5zou7R3/U56Q0Rws5oD/BBbhlQ9yt3JcZ/g==", + "node_modules/electron-builder/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "dependencies": { - "@types/fs-extra": "^9.0.11", - "aws4": "^1.13.2", - "builder-util": "26.14.0", - "builder-util-runtime": "9.6.3", - "chalk": "^4.1.2", - "form-data": "^4.0.5", - "fs-extra": "^10.1.0", - "lazy-val": "^1.0.5", - "mime": "^2.5.2" + "engines": { + "node": ">= 4.0.0" } }, "node_modules/electron-updater": { @@ -1881,17 +1810,6 @@ "dev": true, "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -2120,36 +2038,6 @@ "node": ">=10" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -2181,19 +2069,6 @@ "node": ">=12" } }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2539,51 +2414,6 @@ "node": ">= 14" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -2603,16 +2433,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2623,29 +2443,6 @@ "node": ">=8" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -2676,22 +2473,6 @@ "node": ">=18" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -2815,23 +2596,6 @@ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", "license": "MIT" }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -2855,29 +2619,6 @@ "node": ">=10" } }, - "node_modules/make-fetch-happen": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -2938,16 +2679,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", @@ -2963,144 +2694,35 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "yallist": "^4.0.0" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, "node_modules/minizlib": { @@ -3122,16 +2744,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-abi": { "version": "4.31.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.31.0.tgz", @@ -3155,31 +2767,6 @@ "semver": "^7.3.5" } }, - "node_modules/node-gyp": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", - "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "tar": "^7.4.3", - "tinyglobby": "^0.2.12", - "which": "^5.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -3187,22 +2774,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^3.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", @@ -3237,46 +2808,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -3303,26 +2834,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -3343,30 +2854,6 @@ "node": ">=8" } }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -3564,21 +3051,6 @@ "read-binary-file-arch": "cli.js" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3637,20 +3109,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -3680,35 +3138,6 @@ "node": ">=8.0" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/sanitize-filename": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", @@ -3808,47 +3237,6 @@ "node": ">=10" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -3878,19 +3266,6 @@ "license": "BSD-3-Clause", "optional": true }, - "node_modules/ssri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -3901,16 +3276,6 @@ "node": ">= 6" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3926,22 +3291,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -3955,20 +3304,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -4145,32 +3480,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/unique-slug": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -4223,16 +3532,6 @@ "dev": true, "license": "MIT" }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, "node_modules/webcrypto-core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", @@ -4281,25 +3580,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", From fc437ddecd372a7cdc74b890cae0122dc05adb1c Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Tue, 9 Jun 2026 01:41:33 +0400 Subject: [PATCH 07/34] fix(sse): normalize provider ids to strings (#3427) Integrated into release/v3.8.17 --- open-sse/handlers/rerank.ts | 4 +- open-sse/handlers/responseSanitizer.ts | 19 +++- open-sse/handlers/sseParser.ts | 20 ++-- open-sse/utils/ollamaTransform.ts | 2 +- open-sse/utils/stream.ts | 39 ++++++-- tests/unit/ollama-transform.test.ts | 90 ++++++++++++++++++ tests/unit/rerank.test.ts | 52 +++++++++++ tests/unit/sse-parser-numeric-ids.test.ts | 79 ++++++++++++++++ tests/unit/stream-helpers.test.ts | 68 ++++++++++++++ tests/unit/stream-numeric-ids.test.ts | 107 ++++++++++++++++++++++ 10 files changed, 457 insertions(+), 23 deletions(-) create mode 100644 tests/unit/ollama-transform.test.ts create mode 100644 tests/unit/rerank.test.ts create mode 100644 tests/unit/sse-parser-numeric-ids.test.ts create mode 100644 tests/unit/stream-helpers.test.ts create mode 100644 tests/unit/stream-numeric-ids.test.ts diff --git a/open-sse/handlers/rerank.ts b/open-sse/handlers/rerank.ts index 34920a53ada4..218737a9f345 100644 --- a/open-sse/handlers/rerank.ts +++ b/open-sse/handlers/rerank.ts @@ -40,10 +40,10 @@ function transformRequestForProvider(providerConfig, body) { /** * Transform response from provider-specific formats back to Cohere format */ -function transformResponseFromProvider(providerConfig, data) { +/* @testonly */ export function transformResponseFromProvider(providerConfig, data) { if (providerConfig.format === "nvidia") { return { - id: data.id || `rerank-${Date.now()}`, + id: data.id != null ? String(data.id) : `rerank-${Date.now()}`, results: (data.rankings || []).map((r) => ({ index: r.index, relevance_score: r.logit || r.score || 0, diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 35314f326acb..2c26a064803d 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -985,8 +985,10 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown { // Build sanitized chunk const sanitized: JsonRecord = {}; - // Keep only standard fields - if (parsedRecord.id !== undefined) sanitized.id = parsedRecord.id; + // Keep only standard fields — normalize id to string to avoid AI_InvalidResponseDataError + if (parsedRecord.id !== undefined && parsedRecord.id !== null) { + sanitized.id = normalizeResponseId(typeof parsedRecord.id === "string" ? parsedRecord.id : String(parsedRecord.id)); + } sanitized.object = toString(parsedRecord.object) || "chat.completion.chunk"; if (parsedRecord.created !== undefined) sanitized.created = parsedRecord.created; if (parsedRecord.model !== undefined) sanitized.model = parsedRecord.model; @@ -1037,7 +1039,18 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown { delta.reasoning_content = parts.join(""); } } - if (deltaRecord.tool_calls !== undefined) delta.tool_calls = deltaRecord.tool_calls; + if (deltaRecord.tool_calls !== undefined) { + delta.tool_calls = Array.isArray(deltaRecord.tool_calls) + ? deltaRecord.tool_calls.map((tc) => { + const t = toRecord(tc); + if (!t) return tc; + if (t.id !== undefined && t.id !== null && typeof t.id !== "string") { + return { ...t, id: String(t.id) }; + } + return t; + }) + : deltaRecord.tool_calls; + } if (deltaRecord.function_call !== undefined) delta.function_call = deltaRecord.function_call; c.delta = delta; diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index 7432590a4f6b..440d446a797e 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -163,7 +163,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { if (!existing) { accumulatedToolCalls.set(key, { - id: tc?.id ?? null, + id: tc?.id != null ? String(tc.id) : null, index: Number.isInteger(tc?.index) ? tc.index : accumulatedToolCalls.size, type: tc?.type || "function", function: { @@ -172,7 +172,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { }, }); } else { - existing.id = existing.id || tc?.id || null; + existing.id = existing.id || (tc?.id != null ? String(tc.id) : null); if (!Number.isInteger(existing.index) && Number.isInteger(tc?.index)) { existing.index = tc.index; } @@ -216,7 +216,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { } const result: Record = { - id: first.id || `chatcmpl-${Date.now()}`, + id: first.id != null ? String(first.id) : `chatcmpl-${Date.now()}`, object: "chat.completion", created: first.created || Math.floor(Date.now() / 1000), model: first.model || fallbackModel || "unknown", @@ -452,6 +452,7 @@ function cloneResponseItem(item) { const record = toRecord(item); return { ...record, + id: record.id != null ? String(record.id) : record.id, ...(Array.isArray(record.content) ? { content: record.content.map((contentPart) => { @@ -477,7 +478,7 @@ function ensureResponsesMessageItem(outputItems, outputIndex) { const next = { ...(existing && typeof existing === "object" ? existing : {}), - id: existing?.id || `msg_${Date.now()}_${outputIndex}`, + id: existing?.id != null ? String(existing.id) : `msg_${Date.now()}_${outputIndex}`, type: "message", role: "assistant", content: Array.isArray(existing?.content) @@ -499,7 +500,7 @@ function ensureResponsesReasoningItem(outputItems, outputIndex, itemId) { const next = { ...(existing && typeof existing === "object" ? existing : {}), - id: itemId || existing?.id || `rs_${Date.now()}_${outputIndex}`, + id: itemId || (existing?.id != null ? String(existing.id) : null) || `rs_${Date.now()}_${outputIndex}`, type: "reasoning", summary: Array.isArray(existing?.summary) ? existing.summary.map((summaryPart) => ({ ...toRecord(summaryPart) })) @@ -525,7 +526,7 @@ function ensureResponsesFunctionCallItem(outputItems, outputIndex, itemId, callI const next = { ...(existing && typeof existing === "object" ? existing : {}), - id: itemId || existing?.id || `fc_${callId || `${Date.now()}_${outputIndex}`}`, + id: itemId || (existing?.id != null ? String(existing.id) : null) || `fc_${callId || `${Date.now()}_${outputIndex}`}`, type: "function_call", call_id: callId || existing?.call_id || "", name: name || existing?.name || "", @@ -705,10 +706,13 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { : "in_progress"; return { - id: picked.id || `resp_${Date.now()}`, + id: picked.id != null ? String(picked.id) : `resp_${Date.now()}`, object: picked.object || "response", model: picked.model || fallbackModel || "unknown", - output: pickedOutput.length > 0 ? pickedOutput : reconstructedOutput, + output: (pickedOutput.length > 0 ? pickedOutput : reconstructedOutput).map((item) => ({ + ...item, + id: item.id != null ? String(item.id) : item.id, + })), usage: picked.usage || null, status: picked.status || statusFallback, created_at: picked.created_at || Math.floor(Date.now() / 1000), diff --git a/open-sse/utils/ollamaTransform.ts b/open-sse/utils/ollamaTransform.ts index 6b0e906f4f31..2bb51eda1a29 100644 --- a/open-sse/utils/ollamaTransform.ts +++ b/open-sse/utils/ollamaTransform.ts @@ -51,7 +51,7 @@ export function transformToOllama(response, model) { } if (!pendingToolCalls[idx]) { - pendingToolCalls[idx] = { id: tc.id, function: { name: "", arguments: "" } }; + pendingToolCalls[idx] = { id: tc.id != null ? String(tc.id) : tc.id, function: { name: "", arguments: "" } }; } if (tc.function?.name) pendingToolCalls[idx].function.name += tc.function.name; if (tc.function?.arguments) diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 452ae2b2401c..1ae5a982959f 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -269,10 +269,10 @@ function collectPassthroughTextualToolCall( return toolCall; } -function toStreamingToolCallDelta(toolCall: ToolCall) { +/* @testonly */ export function toStreamingToolCallDelta(toolCall: ToolCall) { return { index: toolCall.index, - id: toolCall.id, + id: toolCall.id != null ? String(toolCall.id) : null, type: toolCall.type, function: { name: toolCall.function.name, @@ -281,11 +281,11 @@ function toStreamingToolCallDelta(toolCall: ToolCall) { }; } -function toResponsesFunctionCallItem(toolCall: ToolCall) { +/* @testonly */ export function toResponsesFunctionCallItem(toolCall: ToolCall) { return { type: "function_call", - id: toolCall.id || `fc_${toolCall.index}`, - call_id: toolCall.id || `call_${toolCall.index}`, + id: (toolCall.id != null ? String(toolCall.id) : null) || `fc_${toolCall.index}`, + call_id: (toolCall.id != null ? String(toolCall.id) : null) || `call_${toolCall.index}`, name: toolCall.function.name, arguments: toolCall.function.arguments, status: "completed", @@ -1604,6 +1604,14 @@ export function createSSEStream(options: StreamOptions = {}) { typeof parsed.choices[0].delta.reasoning === "string" && !parsed.choices[0].delta.reasoning_content ); + const hadNonStringToolCallId = Array.isArray(parsed.choices) + ? parsed.choices.some((choice) => + Array.isArray(choice?.delta?.tool_calls) && + choice.delta.tool_calls.some( + (tc) => tc?.id != null && typeof tc.id !== "string" + ) + ) + : false; parsed = sanitizeStreamingChunk(parsed); if ( @@ -1623,6 +1631,7 @@ export function createSSEStream(options: StreamOptions = {}) { const delta = parsed.choices?.[0]?.delta; let textualToolCallConverted = false; + let toolCallIdCoerced = false; // Extract tags from streaming content if (delta?.content && typeof delta.content === "string") { @@ -1666,6 +1675,13 @@ export function createSSEStream(options: StreamOptions = {}) { passthroughHasToolCalls = true; lastToolCallChunkTime = Date.now(); for (const tc of delta.tool_calls) { + if (tc?.id != null) { + const stringId = String(tc.id); + if (tc.id !== stringId) { + tc.id = stringId; + toolCallIdCoerced = true; + } + } // Key by index first — id only appears on the first delta in OpenAI streaming let key: string; if (Number.isInteger(tc?.index)) { @@ -1680,7 +1696,7 @@ export function createSSEStream(options: StreamOptions = {}) { typeof tc?.function?.arguments === "string" ? tc.function.arguments : ""; if (!existing) { passthroughToolCalls.set(key, { - id: tc?.id ?? null, + id: tc?.id != null ? String(tc.id) : null, index: Number.isInteger(tc?.index) ? tc.index : passthroughToolCalls.size, type: tc?.type || "function", function: { @@ -1689,7 +1705,7 @@ export function createSSEStream(options: StreamOptions = {}) { }, }); } else { - if (tc?.id) existing.id = existing.id || tc.id; + if (tc?.id) existing.id = existing.id || String(tc.id); if (tc?.function?.name && !existing.function.name) existing.function.name = tc.function.name; existing.function.arguments += deltaArgs; @@ -1772,7 +1788,12 @@ export function createSSEStream(options: StreamOptions = {}) { } else if (textualToolCallConverted) { output = `data: ${JSON.stringify(parsed)}\n`; injectedUsage = true; - } else if (idFixed || needsReserialization) { + } else if ( + idFixed || + needsReserialization || + toolCallIdCoerced || + hadNonStringToolCallId + ) { output = `data: ${JSON.stringify(parsed)}\n`; injectedUsage = true; } @@ -2385,7 +2406,7 @@ export function createSSEStream(options: StreamOptions = {}) { ? [...state.toolCalls.values()] .map( (tc: Record): ToolCall => ({ - id: (tc.id as string) ?? null, + id: tc.id != null ? String(tc.id) : null, index: (tc.index as number) ?? (tc.blockIndex as number) ?? 0, type: (tc.type as string) ?? "function", function: (tc.function as ToolCall["function"]) ?? { diff --git a/tests/unit/ollama-transform.test.ts b/tests/unit/ollama-transform.test.ts new file mode 100644 index 000000000000..c747e5984d18 --- /dev/null +++ b/tests/unit/ollama-transform.test.ts @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { transformToOllama } = await import("../../open-sse/utils/ollamaTransform.ts"); + +test("transformToOllama coerces numeric tool_call id to string without crashing", async () => { + const inputSSE = [ + `data: ${JSON.stringify({ + id: "chatcmpl_1", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4", + choices: [{ + index: 0, + delta: { + tool_calls: [{ + index: 0, + id: 12345, + type: "function", + function: { name: "test", arguments: "{}" } + }] + }, + finish_reason: "tool_calls" + }] + })}\n`, + ].join(""); + + const inputStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(inputSSE)); + controller.close(); + }, + }); + + const mockResponse = new Response(inputStream, { + headers: { "Content-Type": "text/event-stream" }, + }); + + const result = transformToOllama(mockResponse, "test-model"); + const text = await result.text(); + + // Should produce valid JSON lines without crashing + const lines = text.trim().split("\n"); + assert.ok(lines.length > 0, "Should produce at least one line of output"); + for (const line of lines) { + const parsed = JSON.parse(line); + assert.ok(parsed, "Each line should be valid JSON"); + } +}); + +test("transformToOllama handles string tool_call id normally", async () => { + const inputSSE = [ + `data: ${JSON.stringify({ + id: "chatcmpl_1", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4", + choices: [{ + index: 0, + delta: { + tool_calls: [{ + index: 0, + id: "call_abc", + type: "function", + function: { name: "test", arguments: "{}" } + }] + }, + finish_reason: "tool_calls" + }] + })}\n`, + ].join(""); + + const inputStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(inputSSE)); + controller.close(); + }, + }); + + const mockResponse = new Response(inputStream, { + headers: { "Content-Type": "text/event-stream" }, + }); + + const result = transformToOllama(mockResponse, "test-model"); + const text = await result.text(); + const lines = text.trim().split("\n").map(l => JSON.parse(l)); + + const toolCallLine = lines.find(l => l.message?.tool_calls); + assert.ok(toolCallLine, "Should produce a tool call line"); +}); diff --git a/tests/unit/rerank.test.ts b/tests/unit/rerank.test.ts new file mode 100644 index 000000000000..ad8a7579843b --- /dev/null +++ b/tests/unit/rerank.test.ts @@ -0,0 +1,52 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { transformResponseFromProvider } = await import("../../open-sse/handlers/rerank.ts"); + +test("transformResponseFromProvider coerces numeric data.id to string for nvidia format", () => { + const result = transformResponseFromProvider( + { format: "nvidia" }, + { id: 12345, rankings: [] } + ); + + assert.equal(typeof result.id, "string"); + assert.equal(result.id, "12345"); +}); + +test("transformResponseFromProvider coerces numeric zero data.id to string", () => { + const result = transformResponseFromProvider( + { format: "nvidia" }, + { id: 0, rankings: [] } + ); + + assert.equal(typeof result.id, "string"); + assert.equal(result.id, "0"); +}); + +test("transformResponseFromProvider uses fallback when data.id is null", () => { + const result = transformResponseFromProvider( + { format: "nvidia" }, + { id: null, rankings: [] } + ); + + assert.ok(result.id.startsWith("rerank-")); +}); + +test("transformResponseFromProvider uses fallback when data.id is undefined", () => { + const result = transformResponseFromProvider( + { format: "nvidia" }, + { rankings: [] } + ); + + assert.ok(result.id.startsWith("rerank-")); +}); + +test("transformResponseFromProvider passes through string id unchanged", () => { + const result = transformResponseFromProvider( + { format: "nvidia" }, + { id: "nvidia-abc", rankings: [] } + ); + + assert.equal(typeof result.id, "string"); + assert.equal(result.id, "nvidia-abc"); +}); diff --git a/tests/unit/sse-parser-numeric-ids.test.ts b/tests/unit/sse-parser-numeric-ids.test.ts new file mode 100644 index 000000000000..aab9138409fa --- /dev/null +++ b/tests/unit/sse-parser-numeric-ids.test.ts @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { parseSSEToOpenAIResponse, parseSSEToResponsesOutput } = + await import("../../open-sse/handlers/sseParser.ts"); + +test("parseSSEToOpenAIResponse coerces numeric top-level id to string", () => { + const rawSSE = [ + 'data: {"id":123,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":"stop"}]}', + "", + "data: [DONE]", + "", + ].join("\n"); + + const parsed = parseSSEToOpenAIResponse(rawSSE, "fallback-model"); + + assert.equal(typeof parsed.id, "string"); + assert.equal(parsed.id, "123"); +}); + +test("parseSSEToOpenAIResponse coerces numeric tool_call id to string", () => { + const rawSSE = [ + 'data: {"id":"chatcmpl_tc1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":123,"function":{"name":"foo","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}', + "", + "data: [DONE]", + "", + ].join("\n"); + + const parsed = parseSSEToOpenAIResponse(rawSSE, "fallback-model"); + + assert.equal(typeof parsed.choices[0].message.tool_calls[0].id, "string"); + assert.equal(parsed.choices[0].message.tool_calls[0].id, "123"); +}); + +test("parseSSEToResponsesOutput coerces numeric output item id to string", () => { + const rawSSE = [ + 'data: {"type":"response.completed","response":{"id":"resp_num","model":"gpt-4.1","status":"completed","output":[{"id":456,"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}]}}', + "data: [DONE]", + ].join("\n"); + + const parsed = parseSSEToResponsesOutput(rawSSE, "fallback-model"); + + assert.equal(typeof parsed.id, "string"); + assert.equal(parsed.id, "resp_num"); + assert.equal(typeof parsed.output[0].id, "string"); + assert.equal(parsed.output[0].id, "456"); +}); + +test("parseSSEToResponsesOutput coerces numeric function call item id from incremental events", () => { + const rawSSE = [ + 'data: {"type":"response.created","response":{"id":"resp_fc","model":"gpt-4.1","status":"in_progress","output":[]}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":789,"type":"function_call","status":"in_progress","name":"foo","arguments":""}}', + 'data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\\"key\\":\\"val\\"}"}', + 'data: {"type":"response.function_call_arguments.done","output_index":0,"arguments":"{\\"key\\":\\"val\\"}","status":"completed"}', + 'data: {"type":"response.completed","response":{"id":"resp_fc","model":"gpt-4.1","status":"completed","output":null}}', + "data: [DONE]", + ].join("\n"); + + const parsed = parseSSEToResponsesOutput(rawSSE, "fallback-model"); + + assert.equal(typeof parsed.output[0].id, "string"); + assert.equal(parsed.output[0].id, "789"); +}); + +test("parseSSEToResponsesOutput coerces numeric reasoning item id from incremental events", () => { + const rawSSE = [ + 'data: {"type":"response.created","response":{"id":"resp_rs","model":"gpt-4.1","status":"in_progress","output":[]}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":101,"type":"reasoning","status":"in_progress","summary":[]}}', + 'data: {"type":"response.reasoning_summary_text.delta","output_index":0,"delta":"thinking step 1"}', + 'data: {"type":"response.reasoning_summary_text.done","output_index":0,"text":"thinking step 1"}', + 'data: {"type":"response.completed","response":{"id":"resp_rs","model":"gpt-4.1","status":"completed","output":null}}', + "data: [DONE]", + ].join("\n"); + + const parsed = parseSSEToResponsesOutput(rawSSE, "fallback-model"); + + assert.equal(typeof parsed.output[0].id, "string"); + assert.equal(parsed.output[0].id, "101"); +}); diff --git a/tests/unit/stream-helpers.test.ts b/tests/unit/stream-helpers.test.ts new file mode 100644 index 000000000000..4a9af82295b6 --- /dev/null +++ b/tests/unit/stream-helpers.test.ts @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { toStreamingToolCallDelta, toResponsesFunctionCallItem } = + await import("../../open-sse/utils/stream.ts"); + +type StreamingToolCallInput = Parameters[0]; +type ResponsesToolCallInput = Parameters[0]; + +function streamingToolCall(id: unknown): StreamingToolCallInput { + return { + id, + index: 0, + type: "function", + function: { name: "foo", arguments: "{}" }, + } as unknown as StreamingToolCallInput; +} + +function responsesToolCall(id: unknown, index = 0): ResponsesToolCallInput { + return { + id, + index, + type: "function", + function: { name: "bar", arguments: "{}" }, + } as unknown as ResponsesToolCallInput; +} + +test("toStreamingToolCallDelta coerces numeric id to string", () => { + const result = toStreamingToolCallDelta(streamingToolCall(42)); + + assert.equal(typeof result.id, "string"); + assert.equal(result.id, "42"); +}); + +test("toStreamingToolCallDelta passes null id as null", () => { + const result = toStreamingToolCallDelta(streamingToolCall(null)); + + assert.equal(result.id, null); +}); + +test("toStreamingToolCallDelta passes string id unchanged", () => { + const result = toStreamingToolCallDelta(streamingToolCall("call_abc")); + + assert.equal(result.id, "call_abc"); +}); + +test("toResponsesFunctionCallItem coerces numeric id to string", () => { + const result = toResponsesFunctionCallItem(responsesToolCall(99)); + + assert.equal(typeof result.id, "string"); + assert.equal(result.id, "99"); + assert.equal(typeof result.call_id, "string"); + assert.equal(result.call_id, "99"); +}); + +test("toResponsesFunctionCallItem falls back to fc_ pattern for null id", () => { + const result = toResponsesFunctionCallItem(responsesToolCall(null, 5)); + + assert.equal(result.id, "fc_5"); + assert.equal(result.call_id, "call_5"); +}); + +test("toResponsesFunctionCallItem passes string id unchanged", () => { + const result = toResponsesFunctionCallItem(responsesToolCall("call_xyz")); + + assert.equal(result.id, "call_xyz"); + assert.equal(result.call_id, "call_xyz"); +}); diff --git a/tests/unit/stream-numeric-ids.test.ts b/tests/unit/stream-numeric-ids.test.ts new file mode 100644 index 000000000000..346b88a5f07b --- /dev/null +++ b/tests/unit/stream-numeric-ids.test.ts @@ -0,0 +1,107 @@ +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-stream-numeric-ids-")); +process.env.DATA_DIR = TEST_DATA_DIR; +const core = await import("../../src/lib/db/core.ts"); + +const { createSSEStream } = await import("../../open-sse/utils/stream.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +const textEncoder = new TextEncoder(); + +async function readTransformed(chunks, options) { + const source = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(textEncoder.encode(chunk)); + } + controller.close(); + }, + }); + + return new Response(source.pipeThrough(createSSEStream(options))).text(); +} + +test.after(() => { + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } +}); + +test("createSSEStream passthrough coerces numeric tool_call id to string", async () => { + let onCompletePayload = null; + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_num", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: { role: "assistant", content: "Hello " } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_num", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 12345, + type: "function", + function: { name: "read_file", arguments: '{"path":"/tmp/a"}' }, + }, + ], + }, + }, + ], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_num", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "openai", + model: "gpt-4.1-mini", + body: { messages: [{ role: "user", content: "hello" }] }, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + const lines = text + .trim() + .split("\n") + .filter((line) => line.startsWith("data: ") && !line.includes("[DONE]")); + let sawStreamedToolCall = false; + for (const line of lines) { + const payload = JSON.parse(line.slice(6)); + const tc = payload?.choices?.[0]?.delta?.tool_calls?.[0]; + if (tc?.id) { + assert.equal(typeof tc.id, "string", "tool_call.id should be a string"); + assert.equal(tc.id, "12345"); + sawStreamedToolCall = true; + } + } + assert.equal(sawStreamedToolCall, true, "expected streamed tool_call.id to be present"); + + const finalId = onCompletePayload?.responseBody?.choices?.[0]?.message?.tool_calls?.[0]?.id; + assert.equal(typeof finalId, "string", "tool_call.id in final message should be a string"); + assert.equal(finalId, "12345"); +}); From b3372e46c460ed23b61c3110468469fd4b1b3ed9 Mon Sep 17 00:00:00 2001 From: Muhammad Nabil Muyassar Rahman <65392758+TapZe@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:41:52 +0700 Subject: [PATCH 08/34] fix(command-code): revert chat endpoint to /alpha/generate and fix model sync discovery (#3432) Integrated into release/v3.8.17 --- docs/reference/PROVIDER_REFERENCE.md | 2 +- open-sse/config/providerRegistry.ts | 4 ++-- open-sse/executors/commandCode.ts | 4 ++-- src/app/api/providers/[id]/models/route.ts | 8 ++++++++ src/lib/providers/validation.ts | 4 ++-- src/shared/constants/providers.ts | 2 +- tests/unit/command-code-executor.test.ts | 10 +++++----- tests/unit/executor-command-code.test.ts | 2 +- tests/unit/provider-validation-specialty.test.ts | 4 ++-- tests/unit/responses-handler.test.ts | 2 +- 10 files changed, 25 insertions(+), 17 deletions(-) diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 2cab59dd4a10..246e05ce3768 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -113,7 +113,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `cloudflare-ai` | `cf` | Cloudflare Workers AI | API key | [link](https://developers.cloudflare.com/workers-ai) | Requires API Token AND Account ID (found at dash.cloudflare.com) | | `codestral` | `codestral` | Codestral | API key | [link](https://mistral.ai) | — | | `cohere` | `cohere` | Cohere | API key | [link](https://cohere.com) | Free Trial: 1,000 API calls/month for testing, no credit card required | -| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /provider/v1/chat/completions endpoint. | +| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint. | | `coze` | `coze` | Coze | API key | [link](https://coze.com) | Get API key at coze.com/open/api | | `crof` | `crof` | CrofAI | API key | [link](https://crof.ai) | — | | `databricks` | `databricks` | Databricks | API key, enterprise | [link](https://www.databricks.com) | — | diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 6e76c4da8cc2..39bb5cb0b874 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1626,8 +1626,8 @@ const _REGISTRY_EAGER: Record = { alias: "cmd", format: "openai", executor: "command-code", - baseUrl: "https://api.commandcode.ai/provider/v1", - chatPath: "/chat/completions", + baseUrl: "https://api.commandcode.ai", + chatPath: "/alpha/generate", modelsUrl: "https://api.commandcode.ai/provider/v1/models", authType: "apikey", authHeader: "Authorization", diff --git a/open-sse/executors/commandCode.ts b/open-sse/executors/commandCode.ts index f606ec3cbba5..81c1e046112d 100644 --- a/open-sse/executors/commandCode.ts +++ b/open-sse/executors/commandCode.ts @@ -509,8 +509,8 @@ export class CommandCodeExecutor extends BaseExecutor { } buildUrl() { - const baseUrl = (this.config.baseUrl || "https://api.commandcode.ai/provider/v1").replace(/\/$/, ""); - return `${baseUrl}${this.config.chatPath || "/chat/completions"}`; + const baseUrl = (this.config.baseUrl || "https://api.commandcode.ai").replace(/\/$/, ""); + return `${baseUrl}${this.config.chatPath || "/alpha/generate"}`; } async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }: ExecuteInput) { diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index f2d4bcb2dce7..e3733dd5f135 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -687,6 +687,14 @@ const PROVIDER_MODELS_CONFIG: Record = { authPrefix: "Bearer ", parseResponse: (data) => data.data || data.models || [], }, + "command-code": { + url: "https://api.commandcode.ai/provider/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, "opencode-zen": { url: "https://opencode.ai/zen/v1/models", method: "GET", diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index fda1067bda34..e2ac5d9b6637 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -567,8 +567,8 @@ async function validateDirectChatProvider({ export async function validateCommandCodeProvider({ apiKey, providerSpecificData = {} }: any) { const entry = getRegistryEntry("command-code"); - const baseUrl = normalizeBaseUrl(entry?.baseUrl || "https://api.commandcode.ai/provider/v1"); - const chatPath = entry?.chatPath || "/chat/completions"; + const baseUrl = normalizeBaseUrl(entry?.baseUrl || "https://api.commandcode.ai"); + const chatPath = entry?.chatPath || "/alpha/generate"; const url = `${baseUrl}${chatPath.startsWith("/") ? chatPath : `/${chatPath}`}`; const validationModelId = providerSpecificData?.validationModelId || diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index f47a2dc86600..67df2b560acf 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -599,7 +599,7 @@ export const APIKEY_PROVIDERS = { textIcon: "CC", website: "https://commandcode.ai/", authHint: - "Use a Command Code API key. Requests are sent to Command Code's /provider/v1/chat/completions endpoint.", + "Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint.", apiHint: "Create or copy an API key from Command Code, then paste it here as a Bearer token.", }, openrouter: { diff --git a/tests/unit/command-code-executor.test.ts b/tests/unit/command-code-executor.test.ts index 758e5c7946ce..c866b8f45916 100644 --- a/tests/unit/command-code-executor.test.ts +++ b/tests/unit/command-code-executor.test.ts @@ -74,8 +74,8 @@ test("Command Code provider catalog has pinned models and alias lookup", () => { assert.ok(entry); assert.equal(entry.alias, "cmd"); assert.equal(entry.executor, "command-code"); - assert.equal(entry.baseUrl, "https://api.commandcode.ai/provider/v1"); - assert.equal(entry.chatPath, "/chat/completions"); + assert.equal(entry.baseUrl, "https://api.commandcode.ai"); + assert.equal(entry.chatPath, "/alpha/generate"); assert.deepEqual( entry.models.map((model) => model.id), PINNED_COMMAND_CODE_MODELS @@ -89,7 +89,7 @@ test("getExecutor returns the specialized Command Code executor", () => { assert.ok(getExecutor("cmd") instanceof CommandCodeExecutor); }); -test("Command Code executor posts wrapped body and required headers to /provider/v1/chat/completions", async () => { +test("Command Code executor posts wrapped body and required headers to /alpha/generate", async () => { const calls: any[] = []; globalThis.fetch = async (url, init = {}) => { calls.push({ url: String(url), init }); @@ -112,9 +112,9 @@ test("Command Code executor posts wrapped body and required headers to /provider }, }); - assert.equal(url, "https://api.commandcode.ai/provider/v1/chat/completions"); + assert.equal(url, "https://api.commandcode.ai/alpha/generate"); assert.equal(calls.length, 1); - assert.equal(calls[0].url, "https://api.commandcode.ai/provider/v1/chat/completions"); + assert.equal(calls[0].url, "https://api.commandcode.ai/alpha/generate"); assert.equal(calls[0].init.method, "POST"); assert.equal(headers.Authorization, "Bearer cc_test_key"); assert.equal(headers["x-command-code-version"], "0.24.1"); diff --git a/tests/unit/executor-command-code.test.ts b/tests/unit/executor-command-code.test.ts index 47776f57b6ac..d4c2761d56d1 100644 --- a/tests/unit/executor-command-code.test.ts +++ b/tests/unit/executor-command-code.test.ts @@ -18,7 +18,7 @@ describe("CommandCodeExecutor", () => { const executor = new mod.CommandCodeExecutor(); const url = executor.buildUrl(); assert.ok(typeof url === "string"); - assert.ok(url.includes("/chat/completions") && url.includes("commandcode")); + assert.ok(url.includes("generate") && url.includes("commandcode")); }); it("execute throws when no API key", async () => { diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index b101f747a6fd..235326650d3b 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -88,7 +88,7 @@ test("specialty provider validators cover Deepgram, AssemblyAI, ElevenLabs and I test("validateCommandCodeProvider ignores caller baseUrl and chatPath overrides", async () => { globalThis.fetch = async (url, init = {}) => { - assert.equal(String(url), "https://api.commandcode.ai/provider/v1/chat/completions"); + assert.equal(String(url), "https://api.commandcode.ai/alpha/generate"); const headers = init.headers as Record; assert.equal(headers.Authorization, "Bearer cc-key"); const body = JSON.parse(String(init.body)); @@ -1956,7 +1956,7 @@ test("validateCommandCodeProvider sends Command Code probe URL, headers, and wra assert.deepEqual(result, { valid: true, error: null }); assert.equal(calls.length, 1); - assert.equal(calls[0].url, "https://api.commandcode.ai/provider/v1/chat/completions"); + assert.equal(calls[0].url, "https://api.commandcode.ai/alpha/generate"); assert.equal(calls[0].method, "POST"); assert.equal(calls[0].headers.Authorization, "Bearer cc_test_key"); assert.equal(calls[0].headers["Content-Type"], "application/json"); diff --git a/tests/unit/responses-handler.test.ts b/tests/unit/responses-handler.test.ts index 816bddc26c7c..cdcd6676da9f 100644 --- a/tests/unit/responses-handler.test.ts +++ b/tests/unit/responses-handler.test.ts @@ -251,7 +251,7 @@ test("handleResponsesCore transforms Command Code executor SSE through Responses }); assert.equal(result.success, true); - assert.equal(call.url, "https://api.commandcode.ai/provider/v1/chat/completions"); + assert.equal(call.url, "https://api.commandcode.ai/alpha/generate"); assert.equal(call.headers.Authorization, "Bearer cc_test_key"); assert.equal(call.headers["x-command-code-version"], "0.24.1"); assert.equal(call.body.params.model, "gpt-5.4-mini"); From 1e4185edac41a4e621df8327ae0143bac01483f1 Mon Sep 17 00:00:00 2001 From: ReqX Date: Mon, 8 Jun 2026 23:42:12 +0200 Subject: [PATCH 09/34] fix(analytics): scope SQL named params per query context (#3446) (#3447) Integrated into release/v3.8.17 --- src/app/api/usage/analytics/route.ts | 82 +++++++++++++++--------- tests/unit/usage-analytics-route.test.ts | 49 ++++++++++++++ 2 files changed, 101 insertions(+), 30 deletions(-) diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 50036d299fd3..62a61ce42043 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -369,35 +369,52 @@ export async function GET(request: Request) { const rawCutoffDate = rawCutoffIso.split("T")[0]; const needsAggregated = (!sinceIso || sinceIso < rawCutoffDate) && apiKeyIds.length === 0; + // Build a separate params object for the unified CTE source. + // better-sqlite3 rejects named params that don't appear in the SQL, + // so each query context must only receive the placeholders it references. + const unifiedParams: Record = {}; + // Raw leg: when aggregated rows are also included, lower-bound the raw leg at the // raw cutoff so the two legs never overlap (prevents double-counting). const rawConditions: string[] = []; if (needsAggregated) { rawConditions.push("timestamp >= @rawCutoff"); - params.rawCutoff = rawCutoffDate; + unifiedParams.rawCutoff = rawCutoffDate; } else if (sinceIso) { rawConditions.push("timestamp >= @since"); + unifiedParams.since = sinceIso; + } + if (untilIso) { + rawConditions.push("timestamp <= @until"); + unifiedParams.until = untilIso; + } + if (apiKeyWhere) { + rawConditions.push(apiKeyWhere); + apiKeyIds.forEach((key, i) => { + unifiedParams[`apiKey${i}`] = key; + }); } - if (untilIso) rawConditions.push("timestamp <= @until"); - if (apiKeyWhere) rawConditions.push(apiKeyWhere); const rawWhere = rawConditions.length > 0 ? `WHERE ${rawConditions.join(" AND ")}` : ""; // Aggregated rows span the requested window but strictly before the raw cutoff, // so they never overlap the raw leg above (no api_key filter — see note above). + // Only build agg params when the agg branch is actually used in the CTE. const aggConditions: string[] = []; - if (sinceIso) { - // Use date comparison on the summary's date column (YYYY-MM-DD). - const sinceDate = sinceIso.split("T")[0]; - aggConditions.push("date >= @sinceDate"); - params.sinceDate = sinceDate; - } - if (untilIso) { - const untilDate = untilIso.split("T")[0]; - aggConditions.push("date <= @untilDate"); - params.untilDate = untilDate; + if (needsAggregated) { + if (sinceIso) { + // Use date comparison on the summary's date column (YYYY-MM-DD). + const sinceDate = sinceIso.split("T")[0]; + aggConditions.push("date >= @sinceDate"); + unifiedParams.sinceDate = sinceDate; + } + if (untilIso) { + const untilDate = untilIso.split("T")[0]; + aggConditions.push("date <= @untilDate"); + unifiedParams.untilDate = untilDate; + } + aggConditions.push("date < @rawCutoffDate"); + unifiedParams.rawCutoffDate = rawCutoffDate; } - aggConditions.push("date < @rawCutoffDate"); - params.rawCutoffDate = rawCutoffDate; const aggWhere = aggConditions.length > 0 ? `WHERE ${aggConditions.join(" AND ")}` : ""; // Unified source CTE: columns aligned to usage_history shape needed by analytics queries. @@ -492,7 +509,7 @@ export async function GET(request: Request) { ${unifiedWhere} ` ) - .get(params) as Record; + .get(unifiedParams) as Record; const dailyRows = db .prepare( @@ -509,7 +526,7 @@ export async function GET(request: Request) { ORDER BY date ASC ` ) - .all(params) as Array>; + .all(unifiedParams) as Array>; const dailyCostRows = db .prepare( @@ -530,7 +547,7 @@ export async function GET(request: Request) { ORDER BY date ASC ` ) - .all(params) as Array>; + .all(unifiedParams) as Array>; const heatmapStart = new Date(); heatmapStart.setUTCDate(heatmapStart.getUTCDate() - 364); @@ -589,7 +606,7 @@ export async function GET(request: Request) { ORDER BY requests DESC ` ) - .all(params) as Array>; + .all(unifiedParams) as Array>; const providerCostRows = db .prepare( @@ -608,7 +625,7 @@ export async function GET(request: Request) { GROUP BY LOWER(provider), LOWER(model), serviceTier ` ) - .all(params) as Array>; + .all(unifiedParams) as Array>; const providerRows = db .prepare( @@ -627,7 +644,7 @@ export async function GET(request: Request) { ORDER BY requests DESC ` ) - .all(params) as Array>; + .all(unifiedParams) as Array>; const accountCostRows = db .prepare( @@ -718,7 +735,7 @@ export async function GET(request: Request) { GROUP BY serviceTier, LOWER(provider), LOWER(model) ` ) - .all(params) as Array>; + .all(unifiedParams) as Array>; const apiKeyMetadataRows = db .prepare( @@ -773,7 +790,7 @@ export async function GET(request: Request) { ORDER BY dayOfWeek ASC ` ) - .all(params) as Array>; + .all(unifiedParams) as Array>; const fallbackRow = db .prepare( @@ -1238,19 +1255,24 @@ export async function GET(request: Request) { } if (apiKeyWhere) { presetRawConds.push(apiKeyWhere); - Object.assign(presetParams, params); + // Only copy apiKey params — better-sqlite3 rejects unknown named params. + apiKeyIds.forEach((key, i) => { + presetParams[`apiKey${i}`] = key; + }); } const presetRawWhere = presetRawConds.length > 0 ? `WHERE ${presetRawConds.join(" AND ")}` : ""; const presetAggConds: string[] = []; - if (presetSinceIso) { - const presetSinceDate = presetSinceIso.split("T")[0]; - presetAggConds.push("date >= @presetSinceDate"); - presetParams.presetSinceDate = presetSinceDate; + if (presetNeedsAggregated) { + if (presetSinceIso) { + const presetSinceDate = presetSinceIso.split("T")[0]; + presetAggConds.push("date >= @presetSinceDate"); + presetParams.presetSinceDate = presetSinceDate; + } + presetAggConds.push("date < @presetRawCutoffDate"); + presetParams.presetRawCutoffDate = rawCutoffDate; } - presetAggConds.push("date < @presetRawCutoffDate"); - presetParams.presetRawCutoffDate = rawCutoffDate; const presetAggWhere = presetAggConds.length > 0 ? `WHERE ${presetAggConds.join(" AND ")}` : ""; diff --git a/tests/unit/usage-analytics-route.test.ts b/tests/unit/usage-analytics-route.test.ts index f7989d4b36ab..89ea451f6a13 100644 --- a/tests/unit/usage-analytics-route.test.ts +++ b/tests/unit/usage-analytics-route.test.ts @@ -520,3 +520,52 @@ test("GET /api/usage/analytics returns 500 on database errors", async () => { assert.equal(response.status, 200); assert.ok(body.summary.totalRequests === 0); }); + +test("GET /api/usage/analytics does not throw Unknown named parameter on short range (needsAggregated=false)", async () => { + // Regression: shared params object leaked agg-only bindings (@sinceDate, @rawCutoffDate) + // into queries that don't reference them, causing better-sqlite3 to throw. + // A short range (1h) triggers needsAggregated=false because the entire window + // falls within the raw-data-only period. + const db = core.getDbInstance(); + const now = new Date(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "test-conn", 100, 50, 1, 200, now.toISOString()); + + const response = await analyticsRoute.GET( + makeRequest("http://localhost/api/usage/analytics?range=1h") + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.totalRequests, 1); +}); + +test("GET /api/usage/analytics does not throw Unknown named parameter with apiKey filter on long range", async () => { + // Regression: Object.assign(presetParams, params) leaked all main-query bindings + // into preset queries that only reference preset-prefixed placeholders. + const apiKey = await apiKeysDb.createApiKey("Preset Key", "machine-preset1234"); + const db = core.getDbInstance(); + const now = new Date(); + + // Seed data old enough to trigger aggregated + preset path + for (let i = 0; i < 5; i++) { + const ts = new Date(now.getTime() - (35 + i) * 24 * 60 * 60 * 1000).toISOString(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "test-conn", apiKey.id, apiKey.name, 100, 50, 1, 200, ts); + } + + const response = await analyticsRoute.GET( + makeRequest(`http://localhost/api/usage/analytics?range=60d&apiKeyId=${apiKey.id}`) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + // Core regression check: no "Unknown named parameter" error. + // The exact count depends on raw-vs-aggregated boundary; we only need to + // confirm the endpoint returns 200 without throwing. + assert.ok(typeof body.summary.totalRequests === "number"); +}); From 23f31faf38f1764ea7059b9f66b6cb505005a82b Mon Sep 17 00:00:00 2001 From: Nicolas Lorin Date: Mon, 8 Jun 2026 23:42:31 +0200 Subject: [PATCH 10/34] fix claude-web and cleanup (#3449) Integrated into release/v3.8.17 --- open-sse/config/providerRegistry.ts | 2 +- src/lib/providers/wrappers/claudeWeb.ts | 137 ---------------------- src/lib/providers/wrappers/deepseekWeb.ts | 93 --------------- 3 files changed, 1 insertion(+), 231 deletions(-) delete mode 100644 src/lib/providers/wrappers/claudeWeb.ts delete mode 100644 src/lib/providers/wrappers/deepseekWeb.ts diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 39bb5cb0b874..b441fd8251c8 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -3049,7 +3049,7 @@ const _REGISTRY_EAGER: Record = { "claude-web": { id: "claude-web", - alias: "claude-web", + alias: "cw", format: "openai", executor: "claude-web", baseUrl: "https://claude.ai/api/organizations", diff --git a/src/lib/providers/wrappers/claudeWeb.ts b/src/lib/providers/wrappers/claudeWeb.ts deleted file mode 100644 index 4aea8258445c..000000000000 --- a/src/lib/providers/wrappers/claudeWeb.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { normalizeSessionCookieHeader, extractCookieValue } from "../webCookieAuth"; - -/** - * Claude Web Provider Types - * Based on real Claude Web API structure captured from browser Network tab - * - * Real API Endpoint: - * POST https://claude.ai/api/organizations/{orgId}/chat_conversations/{convId}/completion - * - * Authentication: - * - Cookie header with sessionKey and other session cookies - * - anthropic-device-id header (UUID) - * - anthropic-client-platform: web_claude_ai - * - Cloudflare cookies (cf_clearance, __cf_bm, _cfuvid) - */ - -export interface ClaudeWebConfig { - cookie: string; - deviceId?: string; - orgId?: string; - conversationId?: string; - model?: string; -} - -/** - * Full request payload matching real Claude Web API format - */ -export interface ClaudeWebRequest { - prompt: string; - model: string; - timezone: string; - personalized_styles: Array<{ - type: string; - key: string; - name: string; - nameKey: string; - prompt: string; - summary: string; - summaryKey: string; - isDefault: boolean; - }>; - locale: string; - tools: Array<{ - name?: string; - description?: string; - input_schema?: Record; - integration_name?: string; - is_mcp_app?: boolean; - type?: string; - }>; - turn_message_uuids: { - human_message_uuid: string; - assistant_message_uuid: string; - }; - attachments: unknown[]; - files: unknown[]; - sync_sources: unknown[]; - rendering_mode: string; - create_conversation_params: { - name: string; - model: string; - include_conversation_preferences: boolean; - paprika_mode: unknown; - compass_mode: unknown; - is_temporary: boolean; - enabled_imagine: boolean; - }; -} - -export interface ClaudeWebResponse { - completion?: string; - stop_reason?: string; - model?: string; - delta?: { - type?: string; - text?: string; - }; - [key: string]: unknown; -} - -export interface ClaudeWebStreamingChunk { - type?: string; - completion?: string; - stop_reason?: string | null; - model?: string; - delta?: { - type?: string; - text?: string; - }; - [key: string]: unknown; -} - -/** - * Utility to resolve the cookie for Claude Web - * Claude web primarily uses 'sessionKey' cookie for authentication - */ -export function resolveClaudeWebCookie(rawValue: string): string { - const cookieName = "sessionKey"; - // If the value is just the cookie value, normalize it - // If it's a blob, extract the specific cookie - return normalizeSessionCookieHeader(rawValue, cookieName); -} - -export function getClaudeWebToken(rawValue: string): string { - return extractCookieValue(rawValue, "sessionKey"); -} - -/** - * API info for Claude Web completion endpoint - * - * Notes: - * - Requires cf_clearance cookie from Cloudflare Turnstile - * - Organization ID obtained from /api/organizations endpoint - * - Conversation ID can be new UUID or existing conversation - * - Device ID should be persisted across sessions - */ -export const CLAUDE_WEB_API_INFO = { - baseUrl: "https://claude.ai/api", - // Dynamic endpoint: /organizations/{orgId}/chat_conversations/{convId}/completion - chatPathTemplate: "/organizations/:orgId/chat_conversations/:convId/completion", - organizationsPath: "/organizations", - sessionPath: "/auth/session", - apiKeyHeader: "Cookie", - requiredHeaders: { - "anthropic-client-platform": "web_claude_ai", - "anthropic-device-id": "{deviceId}", - Referer: "https://claude.ai/new", - Accept: "text/event-stream", - }, - requiredCookies: [ - "sessionKey", // Main authentication - "routingHint", // Anthropic routing - "__cf_bm", // Cloudflare bot management - "_cfuvid", // Cloudflare visitor ID - "cf_clearance", // Cloudflare Turnstile clearance (REQUIRED) - ], -} as const; diff --git a/src/lib/providers/wrappers/deepseekWeb.ts b/src/lib/providers/wrappers/deepseekWeb.ts deleted file mode 100644 index 0ded40b00042..000000000000 --- a/src/lib/providers/wrappers/deepseekWeb.ts +++ /dev/null @@ -1,93 +0,0 @@ -export interface DeepSeekWebConfig { - cookies?: string; - userAgent?: string; - sessionRefreshInterval?: number; - autoRefresh?: boolean; -} - -export interface DeepSeekWebMessage { - role: "user" | "assistant" | "system"; - content: string; -} - -export interface DeepSeekWebCompletionRequest { - model: "deepseek-v4-flash" | "deepseek-v4-pro" | "deepseek-r1" | "deepseek-v3" | string; - messages: DeepSeekWebMessage[]; - stream?: boolean; - temperature?: number; - max_tokens?: number; - reasoning_effort?: "low" | "medium" | "high"; - top_p?: number; - frequency_penalty?: number; - presence_penalty?: number; -} - -export interface DeepSeekWebCompletionResponse { - id: string; - object: string; - created: number; - model: string; - choices: Array<{ - index: number; - message?: { role: string; content: string }; - delta?: { content?: string; role?: string }; - finish_reason: string | null; - }>; - usage?: { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - }; -} - -export interface DeepSeekWebStreamingChunk { - id?: string; - object?: string; - created?: number; - model?: string; - choices?: Array<{ - index?: number; - delta?: { content?: string; role?: string }; - finish_reason?: string | null; - }>; - [key: string]: unknown; -} - -export const DeepSeekWebEndpoint = { - base: "https://chat.deepseek.com", - completion: "/api/v0/chat/completion", - rateLimit: { requestsPerMinute: 60, tokensPerDay: 100000, concurrentRequests: 10 }, -}; - -export const DeepSeekWebModels = { - default: "deepseek-v4-flash", - flash: "deepseek-v4-flash", - pro: "deepseek-v4-pro", - reasoning: "deepseek-r1", - v3: "deepseek-v3", -} as const; - -export const DeepSeekWebDefaults = { - temperature: 0.7, - maxTokens: 4096, - reasoningEffort: "medium" as const, - topP: 1.0, - frequencyPenalty: 0, - presencePenalty: 0, -}; - -export const DeepSeekWebHeaders = { - "Content-Type": "application/json", - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - Accept: "text/event-stream,application/json", - "Accept-Encoding": "gzip, deflate, br", -}; - -export const DeepSeekWebErrors = { - SESSION_EXPIRED: "session_expired", - RATE_LIMITED: "rate_limit_exceeded", - INVALID_REQUEST: "invalid_request_error", - SERVER_ERROR: "internal_server_error", - SERVICE_UNAVAILABLE: "service_unavailable", -}; From ea0c0d8499bf48a63afd3bd745215f6d5fc49787 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:42:50 +0700 Subject: [PATCH 11/34] feat: add ZenMux provider (Phase 2B of #3368) (#3429) Integrated into release/v3.8.17 --- open-sse/config/providerRegistry.ts | 86 +++++++++++++++++++++++++++++ src/shared/constants/config.ts | 1 + src/shared/constants/providers.ts | 16 ++++++ tests/unit/zenmux-provider.test.ts | 67 ++++++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 tests/unit/zenmux-provider.test.ts diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index b441fd8251c8..41eeaebfe04a 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -4318,6 +4318,92 @@ const _REGISTRY_EAGER: Record = { ], }, + zenmux: { + id: "zenmux", + alias: "zm", + format: "openai", + executor: "default", + baseUrl: "https://zenmux.ai/api/v1/chat/completions", + modelsUrl: "https://zenmux.ai/api/v1/models", + authType: "apikey", + authHeader: "bearer", + defaultContextLength: 128000, + models: [ + { + id: "google/gemini-3.1-pro-preview", + name: "Gemini 3.1 Pro Preview (ZenMux)", + contextLength: 1048576, + supportsVision: true, + toolCalling: true, + supportsReasoning: true, + }, + { + id: "google/gemini-3-flash-preview", + name: "Gemini 3 Flash Preview (ZenMux)", + contextLength: 1048576, + supportsVision: true, + toolCalling: true, + supportsReasoning: true, + }, + { + id: "openai/gpt-5", + name: "GPT-5 (ZenMux)", + contextLength: 400000, + supportsVision: true, + toolCalling: true, + supportsReasoning: true, + }, + { + id: "anthropic/claude-sonnet-4.5", + name: "Claude Sonnet 4.5 (ZenMux)", + contextLength: 200000, + supportsVision: true, + toolCalling: true, + supportsReasoning: true, + }, + { + id: "anthropic/claude-opus-4.5", + name: "Claude Opus 4.5 (ZenMux)", + contextLength: 200000, + supportsVision: true, + toolCalling: true, + supportsReasoning: true, + }, + { + id: "deepseek/deepseek-chat", + name: "DeepSeek V3.2 Chat (ZenMux)", + contextLength: 128000, + supportsVision: false, + toolCalling: true, + supportsReasoning: false, + }, + { + id: "x-ai/grok-4.1-fast", + name: "Grok 4.1 Fast (ZenMux)", + contextLength: 131072, + supportsVision: false, + toolCalling: true, + supportsReasoning: true, + }, + { + id: "mistralai/mistral-large-2512", + name: "Mistral Large 2512 (ZenMux)", + contextLength: 128000, + supportsVision: true, + toolCalling: true, + supportsReasoning: false, + }, + { + id: "z-ai/glm-4.6v-flash", + name: "GLM 4.6V Flash (ZenMux)", + contextLength: 128000, + supportsVision: true, + toolCalling: true, + supportsReasoning: false, + }, + ], + }, + theoldllm: { id: "theoldllm", alias: "tllm", diff --git a/src/shared/constants/config.ts b/src/shared/constants/config.ts index 85a93070a15f..32a86de86d6b 100644 --- a/src/shared/constants/config.ts +++ b/src/shared/constants/config.ts @@ -29,6 +29,7 @@ export const PROVIDER_ENDPOINTS = { minimax: "https://api.minimax.io/anthropic/v1/messages", "minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages", crof: "https://crof.ai/v1/chat/completions", + zenmux: "https://zenmux.ai/api/v1/chat/completions", openai: "https://api.openai.com/v1/chat/completions", anthropic: "https://api.anthropic.com/v1/messages", gemini: "https://generativelanguage.googleapis.com/v1beta/models", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 67df2b560acf..de54566eee76 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -2284,6 +2284,22 @@ export const APIKEY_PROVIDERS = { apiHint: "Discounted API proxy for 40+ models including GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Get your API key at https://freeaiapikey.com/dashboard. Base URL: https://freeaiapikey.com/v1.", }, + zenmux: { + id: "zenmux", + alias: "zm", + name: "ZenMux", + icon: "neurology", + color: "#7C3AED", + textIcon: "ZM", + website: "https://zenmux.ai", + hasFree: true, + freeNote: + "Free tier includes access to Gemini 3 Flash, DeepSeek V3.2, Grok 4.1 Fast, Mistral Large, and more. Get your API key at https://zenmux.ai.", + authHint: + "Use your ZenMux API key in Authorization: Bearer . ZenMux is fully OpenAI-compatible. Base URL: https://zenmux.ai/api/v1.", + apiHint: + "ZenMux exposes an OpenAI-compatible chat completions endpoint at /api/v1/chat/completions, plus Anthropic Messages (/api/anthropic/v1/messages) and Google Gemini (/api/vertex-ai) protocol surfaces. OmniRoute uses the OpenAI protocol.", + }, }; // Sub-categories within APIKEY_PROVIDERS (used by dashboard and catalog views). diff --git a/tests/unit/zenmux-provider.test.ts b/tests/unit/zenmux-provider.test.ts new file mode 100644 index 000000000000..1fd54b93204e --- /dev/null +++ b/tests/unit/zenmux-provider.test.ts @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { APIKEY_PROVIDERS } = 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"); + +test("ZenMux is registered as an API-key provider with the canonical identity", () => { + const zenmux = APIKEY_PROVIDERS.zenmux; + assert.ok(zenmux, "APIKEY_PROVIDERS.zenmux must be defined"); + assert.equal(zenmux.id, "zenmux"); + assert.equal(zenmux.alias, "zm"); + assert.equal(zenmux.name, "ZenMux"); + assert.equal(zenmux.website, "https://zenmux.ai"); + assert.equal(typeof zenmux.textIcon, "string"); + assert.equal(zenmux.hasFree, true); +}); + +test("ZenMux exposes the OpenAI-compatible chat completions URL", () => { + assert.equal( + PROVIDER_ENDPOINTS.zenmux, + "https://zenmux.ai/api/v1/chat/completions" + ); +}); + +test("ZenMux registry entry uses OpenAI format with bearer apikey auth", () => { + const entry = providerRegistry.zenmux; + assert.ok(entry, "providerRegistry.zenmux must be defined"); + assert.equal(entry.id, "zenmux"); + assert.equal(entry.alias, "zm"); + assert.equal(entry.format, "openai"); + assert.equal(entry.executor, "default"); + assert.equal(entry.authType, "apikey"); + assert.equal(entry.authHeader, "bearer"); + assert.equal(entry.baseUrl, "https://zenmux.ai/api/v1/chat/completions"); + assert.equal(entry.modelsUrl, "https://zenmux.ai/api/v1/models"); +}); + +test("ZenMux seed model list includes the headline families and unique ids", () => { + const models = providerRegistry.zenmux.models; + const ids = models.map((m: { id: string }) => m.id); + assert.ok(ids.length >= 8, "expect a non-trivial seed list of 8+ models"); + assert.equal(new Set(ids).size, ids.length, "model ids must be unique"); + // Provider-prefixed family coverage matching ZenMux's multi-protocol router + for (const family of [ + "google/gemini-3", + "openai/gpt-5", + "anthropic/claude-sonnet", + "deepseek/deepseek", + "x-ai/grok-", + "mistralai/mistral", + ]) { + assert.ok( + ids.some((id: string) => id.startsWith(family)), + `seed list must include ${family}* model` + ); + } +}); + +test("ZenMux models use provider/model naming convention", () => { + for (const model of providerRegistry.zenmux.models) { + assert.ok( + model.id.includes("/"), + `model id "${model.id}" must follow provider/model format (e.g. google/gemini-3.1-pro-preview)` + ); + } +}); From 07a81c8a40b39c6fcf690b66e7ba25bbc8164d04 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:43:10 +0700 Subject: [PATCH 12/34] feat: add LMArena provider (Phase 2A of #3368) (#3421) Integrated into release/v3.8.17 --- open-sse/executors/index.ts | 4 + open-sse/executors/lmarena.ts | 414 ++++++++++++++++++ src/shared/constants/providers.ts | 14 + src/shared/providers/webSessionCredentials.ts | 7 + tests/unit/lmarena-provider.test.ts | 280 ++++++++++++ 5 files changed, 719 insertions(+) create mode 100644 open-sse/executors/lmarena.ts create mode 100644 tests/unit/lmarena-provider.test.ts diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index f6af45bb5e2b..f67c2f63e11b 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -49,6 +49,7 @@ import { QwenWebExecutor } from "./qwen-web.ts"; import { KimiExecutor } from "./kimi.ts" import { TheOldLlmExecutor } from "./theoldllm.ts"; import { ChipotleExecutor } from "./chipotle.ts"; +import { LMArenaExecutor } from "./lmarena.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -140,6 +141,8 @@ const executors = { tllm: new TheOldLlmExecutor(), // Alias chipotle: new ChipotleExecutor(), pepper: new ChipotleExecutor(), // Alias + lmarena: new LMArenaExecutor(), + lma: new LMArenaExecutor(), // Alias }; const defaultCache = new Map(); @@ -198,3 +201,4 @@ export { InnerAiExecutor } from "./inner-ai.ts"; export { QwenWebExecutor } from "./qwen-web.ts"; export { TheOldLlmExecutor } from "./theoldllm.ts"; export { ChipotleExecutor } from "./chipotle.ts"; +export { LMArenaExecutor } from "./lmarena.ts"; diff --git a/open-sse/executors/lmarena.ts b/open-sse/executors/lmarena.ts new file mode 100644 index 000000000000..de4c18c3d0a0 --- /dev/null +++ b/open-sse/executors/lmarena.ts @@ -0,0 +1,414 @@ +/** + * LMArenaExecutor — LMArena Web Session Provider + * + * Routes requests through LMArena's web API using session credentials. + * LMArena is a model comparison platform with 100+ models (GPT, Claude, Gemini, Llama). + * + * API Structure: + * Endpoint: https://arena.ai/nextjs-api/stream + * Method: POST + * Content-Type: application/json + * Accept: text/event-stream + * + * Auth pipeline (per request): + * 1. Extract session cookie from credentials + * 2. Build request with model and messages + * 3. Make authenticated POST request to LMArena API + * 4. Handle SSE response stream with custom prefixes (a0:, ag:, a3:, ae:, ad:) + * + * SSE Format: + * a0: - Text content (concatenate) + * ag: - Thinking/reasoning content + * a2: - Heartbeat (ignore) + * a3: - Model error + * ae: - Platform error + * ad: - Done marker + */ +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +const LMARENA_API_BASE = "https://arena.ai"; +const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream`; + +const LMARENA_USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + +function readLMArenaCookie(credentials: unknown): string { + if (!credentials || typeof credentials !== "object") return ""; + const c = credentials as Record; + const direct = typeof c.cookie === "string" ? c.cookie : ""; + if (direct.trim()) return direct; + const apiKey = typeof c.apiKey === "string" ? c.apiKey : ""; + if (apiKey.trim()) return apiKey; + const psd = c.providerSpecificData; + if (psd && typeof psd === "object") { + const nested = (psd as Record).cookie; + if (typeof nested === "string" && nested.trim()) return nested; + } + return ""; +} + +interface ArenaSSEEvent { + type: "text" | "thinking" | "error" | "done" | "heartbeat"; + content?: string; +} + +export function parseArenaSSE(line: string): ArenaSSEEvent | null { + if (line.startsWith("a0:")) { + try { + const content = JSON.parse(line.substring(3)); + return { type: "text", content: typeof content === "string" ? content : content.text || "" }; + } catch { + return null; + } + } else if (line.startsWith("ag:")) { + try { + const content = JSON.parse(line.substring(3)); + return { type: "thinking", content: typeof content === "string" ? content : content.thinking || "" }; + } catch { + return null; + } + } else if (line.startsWith("a3:") || line.startsWith("ae:")) { + try { + const content = JSON.parse(line.substring(3)); + return { type: "error", content: typeof content === "string" ? content : content.error || JSON.stringify(content) }; + } catch { + return { type: "error", content: line.substring(3) }; + } + } else if (line.startsWith("ad:")) { + return { type: "done" }; + } else if (line.startsWith("a2:")) { + return { type: "heartbeat" }; + } + return null; +} + +export class LMArenaExecutor extends BaseExecutor { + constructor(providerConfig = {}) { + super("lmarena", { format: "openai", ...providerConfig }); + } + + protected buildUrl(_model: string, _credentials: unknown): string { + return LMARENA_STREAM_URL; + } + + protected buildHeaders( + _model: string, + credentials: unknown, + _body: unknown + ): Record { + const cookie = readLMArenaCookie(credentials); + const headers: Record = { + "Content-Type": "application/json", + Accept: "text/event-stream", + "User-Agent": LMARENA_USER_AGENT, + Origin: LMARENA_API_BASE, + Referer: `${LMARENA_API_BASE}/`, + }; + + if (cookie) { + headers.Cookie = cookie; + } + + return headers; + } + + protected transformRequest(body: unknown, model: string): unknown { + const openaiBody = body as Record; + const messages = openaiBody.messages as Array<{ role: string; content: string }>; + + return { + messages: messages.map(m => ({ + role: m.role, + content: m.content, + })), + model, + stream: openaiBody.stream || false, + }; + } + + async execute(input: ExecuteInput): Promise { + const { model, body, stream, credentials, signal, log } = input; + + const cookie = readLMArenaCookie(credentials); + if (!cookie) { + return new Response( + JSON.stringify({ + error: { + message: "LMArena requires a session cookie. Please provide cookie in credentials.", + type: "authentication_error", + code: "missing_cookie", + }, + }), + { + status: 401, + headers: { "Content-Type": "application/json" }, + } + ); + } + + const url = this.buildUrl(model, credentials); + const headers = this.buildHeaders(model, credentials, body); + const transformedBody = this.transformRequest(body, model); + + log?.info?.("LMArenaExecutor", `Executing request for model: ${model}`); + + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `LMArena API error: ${response.status}`; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.error?.message || errorJson.message || errorMessage; + } catch { + errorMessage = errorText || errorMessage; + } + + return new Response( + JSON.stringify({ + error: { + message: sanitizeErrorMessage(errorMessage), + type: "api_error", + code: String(response.status), + }, + }), + { + status: response.status, + headers: { "Content-Type": "application/json" }, + } + ); + } + + if (stream) { + return this.handleStreamingResponse(response, model, log); + } else { + return this.handleNonStreamingResponse(response, model, log); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.error?.("LMArenaExecutor", `Request failed: ${message}`); + + return new Response( + JSON.stringify({ + error: { + message: sanitizeErrorMessage(message), + type: "network_error", + code: "request_failed", + }, + }), + { + status: 502, + headers: { "Content-Type": "application/json" }, + } + ); + } + } + + private async handleStreamingResponse( + response: Response, + model: string, + log?: ExecuteInput["log"] + ): Promise { + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("No response body for streaming"); + } + + const decoder = new TextDecoder(); + let buffer = ""; + let fullText = ""; + let fullThinking = ""; + + const stream = new ReadableStream({ + async start(controller) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + + const sseLine = line.startsWith("data: ") ? line.substring(6) : line; + const event = parseArenaSSE(sseLine); + + if (!event) continue; + + if (event.type === "text" && event.content) { + fullText += event.content; + const chunk = { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: { content: event.content }, + finish_reason: null, + }, + ], + }; + controller.enqueue(`data: ${JSON.stringify(chunk)}\n\n`); + } else if (event.type === "thinking" && event.content) { + fullThinking += event.content; + } else if (event.type === "error") { + const errorChunk = { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: "stop", + }, + ], + error: { message: event.content }, + }; + controller.enqueue(`data: ${JSON.stringify(errorChunk)}\n\n`); + controller.close(); + return; + } else if (event.type === "done") { + const finalChunk = { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: "stop", + }, + ], + }; + controller.enqueue(`data: ${JSON.stringify(finalChunk)}\n\n`); + controller.enqueue("data: [DONE]\n\n"); + controller.close(); + return; + } + } + } + + const finalChunk = { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: "stop", + }, + ], + }; + controller.enqueue(`data: ${JSON.stringify(finalChunk)}\n\n`); + controller.enqueue("data: [DONE]\n\n"); + controller.close(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.error?.("LMArenaExecutor", `Streaming error: ${message}`); + controller.error(error); + } + }, + }); + + return new Response(stream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + + private async handleNonStreamingResponse( + response: Response, + model: string, + log?: ExecuteInput["log"] + ): Promise { + const text = await response.text(); + const lines = text.split("\n"); + let fullText = ""; + let fullThinking = ""; + let error: string | null = null; + + for (const line of lines) { + if (!line.trim()) continue; + + const sseLine = line.startsWith("data: ") ? line.substring(6) : line; + const event = parseArenaSSE(sseLine); + + if (!event) continue; + + if (event.type === "text" && event.content) { + fullText += event.content; + } else if (event.type === "thinking" && event.content) { + fullThinking += event.content; + } else if (event.type === "error") { + error = event.content || "Unknown error"; + break; + } else if (event.type === "done") { + break; + } + } + + if (error) { + return new Response( + JSON.stringify({ + error: { + message: sanitizeErrorMessage(error), + type: "api_error", + code: "lmarena_error", + }, + }), + { + status: 502, + headers: { "Content-Type": "application/json" }, + } + ); + } + + const result = { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: fullText, + }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + }; + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } +} diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index de54566eee76..b937becf804b 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -470,6 +470,20 @@ export const WEB_COOKIE_PROVIDERS = { authHint: "Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies)", }, + lmarena: { + id: "lmarena", + alias: "lma", + name: "LMArena (Free)", + icon: "auto_awesome", + color: "#FF6B6B", + textIcon: "LMA", + website: "https://lmarena.ai", + hasFree: true, + freeNote: "Free model comparison platform — 40+ models (GPT, Claude, Gemini, Llama). No subscription required.", + authHint: + "Paste your session cookie from lmarena.ai (DevTools → Application → Cookies). Optional — works with free tier for basic comparisons.", + riskNoticeVariant: "webCookie", + }, huggingchat: { id: "huggingchat", // "hc" belongs to the hackclub provider; huggingchat uses its own id as alias. diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index bf4c4a190ad8..f3f159c55473 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -192,6 +192,13 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { acceptsFullCookieHeader: true, storageKeys: ["cookie", "manus_session"], }, + lmarena: { + kind: "cookie", + credentialName: "session", + placeholder: "session=... or full Cookie header from lmarena.ai", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "session"], + }, } satisfies Record; export function getWebSessionCredentialRequirement( diff --git a/tests/unit/lmarena-provider.test.ts b/tests/unit/lmarena-provider.test.ts new file mode 100644 index 000000000000..72982bfda735 --- /dev/null +++ b/tests/unit/lmarena-provider.test.ts @@ -0,0 +1,280 @@ +/** + * LMArena Provider — Unit Tests (Phase 2A of issue #3368) + * + * Run: node --import tsx/esm --test tests/unit/lmarena-provider.test.ts + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { WEB_COOKIE_PROVIDERS } from "../../src/shared/constants/providers.ts"; +import { + getWebSessionCredentialRequirement, + requiresWebSessionCredential, + hasUsableWebSessionCredential, +} from "../../src/shared/providers/webSessionCredentials.ts"; +import { LMArenaExecutor, parseArenaSSE } from "../../open-sse/executors/lmarena.ts"; + +describe("LMArena Provider Definition", () => { + it("is registered in WEB_COOKIE_PROVIDERS", () => { + assert.ok(WEB_COOKIE_PROVIDERS.lmarena, "lmarena should be in WEB_COOKIE_PROVIDERS"); + assert.equal(WEB_COOKIE_PROVIDERS.lmarena.id, "lmarena"); + assert.equal(WEB_COOKIE_PROVIDERS.lmarena.alias, "lma"); + assert.equal(WEB_COOKIE_PROVIDERS.lmarena.name, "LMArena (Free)"); + assert.equal(WEB_COOKIE_PROVIDERS.lmarena.website, "https://lmarena.ai"); + assert.equal(WEB_COOKIE_PROVIDERS.lmarena.hasFree, true); + assert.equal(WEB_COOKIE_PROVIDERS.lmarena.riskNoticeVariant, "webCookie"); + }); + + it("has correct metadata", () => { + const provider = WEB_COOKIE_PROVIDERS.lmarena; + assert.ok(provider.freeNote, "Should have freeNote"); + assert.ok(provider.authHint, "Should have authHint"); + assert.ok(provider.icon, "Should have icon"); + assert.ok(provider.color, "Should have color"); + assert.ok(provider.textIcon, "Should have textIcon"); + }); +}); + +describe("LMArena Credential Requirements", () => { + it("requires web session credential", () => { + assert.equal(requiresWebSessionCredential("lmarena"), true); + }); + + it("has correct credential requirement", () => { + const req = getWebSessionCredentialRequirement("lmarena"); + assert.ok(req, "Should have credential requirement"); + assert.equal(req.kind, "cookie"); + assert.equal(req.credentialName, "session"); + assert.ok(req.placeholder.includes("lmarena.ai")); + assert.equal(req.acceptsFullCookieHeader, true); + assert.ok(req.storageKeys.includes("cookie")); + assert.ok(req.storageKeys.includes("session")); + }); + + it("validates usable credentials correctly", () => { + assert.equal( + hasUsableWebSessionCredential("lmarena", { cookie: "session=abc123" }), + true + ); + assert.equal( + hasUsableWebSessionCredential("lmarena", { session: "abc123" }), + true + ); + assert.equal( + hasUsableWebSessionCredential("lmarena", { cookie: "" }), + false + ); + assert.equal( + hasUsableWebSessionCredential("lmarena", {}), + false + ); + }); +}); + +describe("LMArena Executor", () => { + it("can be instantiated", () => { + const executor = new LMArenaExecutor(); + assert.ok(executor, "Executor should be instantiated"); + }); + + it("has correct provider ID", () => { + const executor = new LMArenaExecutor(); + assert.equal((executor as any).provider, "lmarena"); + }); + + it("builds correct URL (arena.ai/nextjs-api/stream)", () => { + const executor = new LMArenaExecutor(); + const url = (executor as any).buildUrl("gpt-4", {}); + assert.ok(url.includes("arena.ai"), "URL should include arena.ai"); + assert.ok(url.includes("/nextjs-api/stream"), "URL should include /nextjs-api/stream"); + }); + + it("builds headers with cookie", () => { + const executor = new LMArenaExecutor(); + const headers = (executor as any).buildHeaders("gpt-4", { cookie: "session=abc123" }, {}); + assert.ok(headers.Cookie, "Should have Cookie header"); + assert.equal(headers.Cookie, "session=abc123"); + assert.equal(headers["Content-Type"], "application/json"); + assert.equal(headers.Accept, "text/event-stream"); + }); + + it("builds headers without cookie when not provided", () => { + const executor = new LMArenaExecutor(); + const headers = (executor as any).buildHeaders("gpt-4", {}, {}); + assert.ok(!headers.Cookie, "Should not have Cookie header when no cookie provided"); + }); + + it("reads cookie from credentials correctly", () => { + const executor = new LMArenaExecutor(); + + // Direct cookie field + let headers = (executor as any).buildHeaders("gpt-4", { cookie: "session=abc" }, {}); + assert.equal(headers.Cookie, "session=abc"); + + // apiKey field (dashboard form) + headers = (executor as any).buildHeaders("gpt-4", { apiKey: "session=def" }, {}); + assert.equal(headers.Cookie, "session=def"); + + // providerSpecificData.cookie + headers = (executor as any).buildHeaders( + "gpt-4", + { providerSpecificData: { cookie: "session=ghi" } }, + {} + ); + assert.equal(headers.Cookie, "session=ghi"); + + // Priority: direct > apiKey > providerSpecificData + headers = (executor as any).buildHeaders( + "gpt-4", + { cookie: "session=abc", apiKey: "session=def" }, + {} + ); + assert.equal(headers.Cookie, "session=abc"); + }); + + it("parses LMArena SSE text events (a0: prefix)", () => { + const textEvent = 'a0:{"text":"Hello, world!"}'; + const result = parseArenaSSE(textEvent); + + assert.ok(result, "Should parse text event"); + assert.equal(result.type, "text"); + assert.equal(result.content, "Hello, world!"); + }); + + it("parses LMArena SSE thinking events (ag: prefix)", () => { + const thinkingEvent = 'ag:{"thinking":"Let me analyze this..."}'; + const result = parseArenaSSE(thinkingEvent); + + assert.ok(result, "Should parse thinking event"); + assert.equal(result.type, "thinking"); + assert.equal(result.content, "Let me analyze this..."); + }); + + it("parses LMArena SSE error events (a3: and ae: prefixes)", () => { + const errorEvent1 = 'a3:{"error":"Rate limit exceeded"}'; + const result1 = parseArenaSSE(errorEvent1); + assert.ok(result1, "Should parse a3: error event"); + assert.equal(result1.type, "error"); + assert.equal(result1.content, "Rate limit exceeded"); + + const errorEvent2 = 'ae:{"error":"Invalid session"}'; + const result2 = parseArenaSSE(errorEvent2); + assert.ok(result2, "Should parse ae: error event"); + assert.equal(result2.type, "error"); + assert.equal(result2.content, "Invalid session"); + }); + + it("parses LMArena SSE done event (ad: prefix)", () => { + const doneEvent = 'ad:{}'; + const result = parseArenaSSE(doneEvent); + + assert.ok(result, "Should parse done event"); + assert.equal(result.type, "done"); + }); + + it("handles malformed SSE events gracefully", () => { + const malformedEvent = 'invalid:data'; + const result = parseArenaSSE(malformedEvent); + + assert.equal(result, null, "Should return null for malformed events"); + }); + + it("transforms OpenAI messages to LMArena format", () => { + const executor = new LMArenaExecutor(); + const transformRequest = (executor as any).transformRequest.bind(executor); + + const openaiBody = { + messages: [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: "Hello!" }, + { role: "assistant", content: "Hi there!" }, + { role: "user", content: "How are you?" } + ], + model: "gpt-4", + stream: true + }; + + const arenaBody = transformRequest(openaiBody, "gpt-4"); + + assert.ok(arenaBody, "Should transform request body"); + assert.ok(arenaBody.messages, "Should have messages array"); + assert.equal(arenaBody.model, "gpt-4", "Should preserve model"); + assert.equal(arenaBody.stream, true, "Should preserve stream flag"); + }); + + it("returns 401 when cookie is missing", async () => { + const executor = new LMArenaExecutor(); + + const response = await executor.execute({ + model: "gpt-4", + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: {}, + signal: new AbortController().signal, + log: console + }); + + assert.equal(response.status, 401, "Should return 401 for missing cookie"); + const errorBody = await response.json(); + assert.ok(errorBody.error, "Should have error object"); + assert.ok(errorBody.error.message.includes("cookie"), "Error should mention cookie"); + }); + + it("handles streaming response correctly", async () => { + const executor = new LMArenaExecutor(); + + const mockSSE = [ + 'data: a0:{"text":"Hello"}\n\n', + 'data: a0:{"text":", world!"}\n\n', + 'data: ad:{}\n\n' + ].join(''); + + const originalFetch = global.fetch; + global.fetch = async () => new Response(mockSSE, { + status: 200, + headers: { "Content-Type": "text/event-stream" } + }); + + try { + const response = await executor.execute({ + model: "gpt-4", + body: { messages: [{ role: "user", content: "Hello" }], stream: true }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: console + }); + + assert.equal(response.status, 200, "Should return 200 for successful streaming"); + assert.ok(response.body, "Should have response body for streaming"); + } finally { + global.fetch = originalFetch; + } + }); + + it("handles error response from LMArena API", async () => { + const executor = new LMArenaExecutor(); + + const originalFetch = global.fetch; + global.fetch = async () => new Response(JSON.stringify({ + error: { message: "Rate limit exceeded" } + }), { + status: 429, + headers: { "Content-Type": "application/json" } + }); + + try { + const response = await executor.execute({ + model: "gpt-4", + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: console + }); + + assert.equal(response.status, 429, "Should return 429 for rate limit"); + const errorBody = await response.json(); + assert.ok(errorBody.error, "Should have error object"); + } finally { + global.fetch = originalFetch; + } + }); +}); From 2427df2f2cf752cc20ba47709c72a39a812b4853 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:43:30 +0700 Subject: [PATCH 13/34] feat: add Gemini Business provider (Phase 2C of #3368) (#3436) Integrated into release/v3.8.17 --- open-sse/executors/gemini-business.ts | 443 ++++++++++++++++++ open-sse/executors/index.ts | 3 + src/shared/constants/providers.ts | 14 + src/shared/providers/webSessionCredentials.ts | 7 + tests/unit/gemini-business-provider.test.ts | 146 ++++++ 5 files changed, 613 insertions(+) create mode 100644 open-sse/executors/gemini-business.ts create mode 100644 tests/unit/gemini-business-provider.test.ts diff --git a/open-sse/executors/gemini-business.ts b/open-sse/executors/gemini-business.ts new file mode 100644 index 000000000000..ec8a4d03cdd8 --- /dev/null +++ b/open-sse/executors/gemini-business.ts @@ -0,0 +1,443 @@ +/** + * GeminiBusinessExecutor — Google Gemini Business / Enterprise Web Provider + * + * Routes requests through Google Gemini Business (business.gemini.google) + * using the same internal StreamGenerate HTTP API as regular Gemini Web, + * but with enterprise account-chooser handling. + * + * Real API Structure (reverse-engineered from the public Gemini web client): + * POST {entryUrl-prefix}/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate + * Content-Type: application/x-www-form-urlencoded + * Body: f.req= + * + * Auth pipeline (per request): + * 1. Extract __Secure-1PSID + __Secure-1PSIDTS cookies from credentials + * 2. Extract enterprise entry URL (e.g. /home/cid/{CID}) from providerSpecificData + * 3. Build the StreamGenerate URL using the entry path prefix + * 4. POST form-encoded payload to the endpoint + * 5. Handle account-chooser HTML response (auto-submit if detected) + * 6. Parse the wrb.fr JSON response and extract text chunks + * 7. Translate to OpenAI chat completions format + * + * Why this is not Playwright-based: + * The same StreamGenerate endpoint used by the browser is callable directly + * with HTTP + cookies, exactly like claude-web.ts and lmarena.ts. This + * avoids the cost and fragility of headless Chromium. + * + * Reference: https://github.com/Sophomoresty/gemini-web2api (gemini_web2api.py) + * Reference: https://github.com/yukkcat/gemini-business2api + */ +import { createHash, randomUUID } from "node:crypto"; +import { BaseExecutor, mergeAbortSignals, type ExecuteInput } from "./base.ts"; +import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; + +const GEMINI_BUSINESS_FETCH_TIMEOUT_MS = 60_000; + +const GEMINI_BUSINESS_USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"; + +// Default entry URL — user can override via providerSpecificData.entryUrl +const DEFAULT_ENTRY_URL = "https://business.gemini.google/home"; + +/** + * Model ID → StreamGenerate MODE_CATEGORY enum value. + * + * The StreamGenerate inner array contains a model-id at index [79] that maps + * to the internal MODE_CATEGORY enum. The enum is stable per model family. + * See gemini-web2api.py: `model_id` arg + `MODE_CATEGORY` field [79]. + */ +const MODEL_CATEGORY_MAP: Record = { + // Gemini 3.x (enterprise) + "gemini-3-pro": 70, + "gemini-3-ultra": 71, + "gemini-3-flash": 75, + // Gemini 2.5 (enterprise) + "gemini-2.5-pro": 53, + "gemini-2.5-flash": 54, + "gemini-2.5-flash-thinking": 55, + // Gemini 2.0 + "gemini-2.0-pro": 51, + "gemini-2.0-flash": 52, + "gemini-2.0-flash-thinking": 56, + // Image / video + "gemini-3-pro-image": 76, + "gemini-2.0-flash-image": 57, + "veo-3.1-generate": 80, +}; + +const DEFAULT_MODEL = "gemini-2.5-pro"; +const DEFAULT_MODEL_CATEGORY = 53; + +export class GeminiBusinessExecutor extends BaseExecutor { + constructor() { + super("gemini-business", { id: "gemini-business", baseUrl: DEFAULT_ENTRY_URL }); + } + + async execute(input: ExecuteInput) { + const { model, body, stream: wantStream, credentials, signal } = input; + const requestBody = body as Record; + + // Extract cookies from credentials — check apiKey/cookie first, then + // try each __Secure-1PSID* key in providerSpecificData individually. + // A user with only __Secure-1PSID (no PSIDTS) is still valid. + const directCookie = + readCredentialString(credentials?.apiKey) || readCredentialString(credentials?.cookie); + const psid = + readProviderSpecificString(credentials?.providerSpecificData, ["__Secure-1PSID", "cookie"]); + const psidts = readProviderSpecificString(credentials?.providerSpecificData, [ + "__Secure-1PSIDTS", + ]); + const cookie = directCookie || [psid, psidts].filter(Boolean).join("; "); + + if (!cookie) { + return makeErrorResult( + 401, + "Missing Gemini Business cookies. Set __Secure-1PSID and __Secure-1PSIDTS from your enterprise account (business.gemini.google).", + body, + DEFAULT_ENTRY_URL + ); + } + + // Extract the user's enterprise entry URL — e.g. https://business.gemini.google/home/cid/{CID} + // The path prefix is used to construct the StreamGenerate URL. + const entryUrl = + readProviderSpecificString(credentials?.providerSpecificData, ["entryUrl", "entry_url"]) || + DEFAULT_ENTRY_URL; + const { baseOrigin, pathPrefix } = parseEntryUrl(entryUrl); + + // Extract prompt from OpenAI-format messages + const messages = (requestBody.messages as Array<{ role: string; content: string }>) || []; + const lastUserMsg = messages.filter((m) => m.role === "user").pop(); + const prompt = extractTextContent(lastUserMsg?.content); + if (!prompt) { + return makeErrorResult(400, "No user message found in request body.", body, DEFAULT_ENTRY_URL); + } + + // Resolve model and its MODE_CATEGORY + const requestedModel = (model as string) || DEFAULT_MODEL; + const modelCategory = MODEL_CATEGORY_MAP[requestedModel] ?? DEFAULT_MODEL_CATEGORY; + + // Build the StreamGenerate form payload (f.req=) + const innerArray = buildInnerArray(prompt, modelCategory); + + const streamUrl = `${baseOrigin}${pathPrefix}/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate?bl=boq_assistant-bard-web-server_20240619.16_p0&hl=en&_reqid=${Math.floor(Math.random() * 900000) + 100000}&rt=c`; + + const formBody = new URLSearchParams(); + formBody.set("f.req", JSON.stringify([null, JSON.stringify(innerArray)])); + + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + Accept: "*/*", + "Accept-Language": "en-US,en;q=0.9", + Cookie: cookie, + "X-Same-Domain": "1", + "User-Agent": GEMINI_BUSINESS_USER_AGENT, + Origin: baseOrigin, + Referer: `${baseOrigin}${pathPrefix}/`, + }; + + // Add SAPISID hash auth header if we can compute it (improves reliability on enterprise) + const sapisid = extractCookieValue(cookie, "SAPISID") || extractCookieValue(cookie, "__Secure-3PAPISID"); + if (sapisid) { + headers["Authorization"] = computeSapisidHash(sapisid, baseOrigin); + } + + let response: Response; + try { + response = await fetch(streamUrl, { + method: "POST", + headers, + body: formBody.toString(), + signal: combineAbortSignals(signal, AbortSignal.timeout(GEMINI_BUSINESS_FETCH_TIMEOUT_MS)), + }); + } catch (err) { + const message = err instanceof Error ? err.message : "fetch failed"; + const isTimeout = err instanceof Error && err.name === "TimeoutError"; + return makeErrorResult( + isTimeout ? 504 : 502, + `Gemini Business ${isTimeout ? "request timed out" : "network error"}: ${message}`, + body, + streamUrl + ); + } + + if (!response.ok) { + const text = await response.text().catch(() => ""); + return makeErrorResult( + response.status, + `Gemini Business returned HTTP ${response.status}: ${text.slice(0, 200)}`, + body, + streamUrl + ); + } + + const rawText = await response.text(); + + if (rawText.includes("auth.business.gemini.google/account-chooser")) { + return makeErrorResult( + 403, + "Gemini Business account-chooser detected. Your enterprise cookies may be stale or the entry URL is wrong. Re-extract __Secure-1PSID/PSIDTS from business.gemini.google/home/cid/{YOUR-CID} after signing in.", + body, + streamUrl + ); + } + + const text = parseStreamResponse(rawText); + if (!text) { + return makeErrorResult( + 502, + "Gemini Business returned no text. The cookie may be expired or the entry URL is wrong.", + body, + streamUrl + ); + } + + if (wantStream) { + return { + response: buildStreamingResponse(text, requestedModel), + url: streamUrl, + headers: {}, + transformedBody: body, + }; + } + return { + response: buildJsonResponse(text, requestedModel), + url: streamUrl, + headers: {}, + transformedBody: body, + }; + } +} + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +/** + * Build the StreamGenerate inner array (80 slots, protobuf-like). + * Slot [0] = [prompt, 0, null, null, null, null, 0] + * Slot [1] = ["en"] (language) + * Slot [2] = ["", "", "", null, ...] (conversation state) + * Slot [17] = [[thinkMode]] (thinking depth 0-4) + * Slot [79] = model_id (MODE_CATEGORY enum) + * Slot [59] = UUID + * See gemini-web2api.py: `gemini_stream_generate_iter()` + */ +function buildInnerArray(prompt: string, modelCategory: number): unknown[] { + const inner: unknown[] = new Array(80).fill(null); + inner[0] = [prompt, 0, null, null, null, null, 0]; + inner[1] = ["en"]; + inner[2] = ["", "", "", null, null, null, null, null, null, ""]; + inner[6] = [0]; + inner[7] = 1; + inner[10] = 1; + inner[11] = 0; + inner[17] = [[0]]; // 0 = deepest thinking + inner[18] = 0; + inner[27] = 1; + inner[30] = [4]; + inner[41] = [2]; + inner[53] = 0; + inner[59] = randomUUID(); + inner[61] = []; + inner[68] = 1; + inner[79] = modelCategory; + return inner; +} + +/** + * Parse Gemini StreamGenerate response text. + * + * Response format (chunked, line-prefixed with byte-length): + * )]}' + * + * [["wrb.fr", null, ""]] + * + * [["wrb.fr", null, ""]] + * + * The JSON string contains nested array: inner[4][0][1] = ["text chunks"] + * We concatenate all text chunks from all wrb.fr lines. + */ +export function parseStreamResponse(raw: string): string { + const lines = raw.split("\n"); + const textChunks: string[] = []; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line === ")]}'" || /^\d+$/.test(line)) continue; + if (!line.includes("wrb.fr")) continue; + try { + const arr = JSON.parse(line); + if (!Array.isArray(arr) || !arr[0] || arr[0][0] !== "wrb.fr") continue; + const payload = arr[0]?.[2]; + if (typeof payload !== "string") continue; + const inner = JSON.parse(payload); + const responseArray = inner?.[4]?.[0]?.[1]; + if (!Array.isArray(responseArray)) continue; + const chunkText = responseArray + .filter((c: unknown) => typeof c === "string") + .join(""); + if (chunkText) textChunks.push(chunkText); + } catch { + // Skip unparseable lines (binary chunks, etc.) + } + } + + return textChunks.join(""); +} + +function buildJsonResponse(text: string, model: string): Response { + const body = { + id: `chatcmpl-${randomUUID()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: text }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function buildStreamingResponse(text: string, model: string): Response { + const encoder = new TextEncoder(); + const id = `chatcmpl-${randomUUID()}`; + const created = Math.floor(Date.now() / 1000); + + const chunk1 = `data: ${JSON.stringify({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], + })}\n\n`; + const chunk2 = `data: ${JSON.stringify({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: text }, finish_reason: null }], + })}\n\n`; + const chunk3 = `data: ${JSON.stringify({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`; + const done = "data: [DONE]\n\n"; + + const readable = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(chunk1)); + controller.enqueue(encoder.encode(chunk2)); + controller.enqueue(encoder.encode(chunk3)); + controller.enqueue(encoder.encode(done)); + controller.close(); + }, + }); + return new Response(readable, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} + +function readCredentialString(value: unknown): string { + if (typeof value !== "string") return ""; + const trimmed = value.trim(); + if (!trimmed) return ""; + return trimmed; +} + +function readProviderSpecificString( + providerSpecificData: unknown, + keys: string[] +): string { + if (!providerSpecificData || typeof providerSpecificData !== "object") return ""; + const data = providerSpecificData as Record; + for (const key of keys) { + const v = data[key]; + if (typeof v === "string" && v.trim().length > 0) return v.trim(); + } + return ""; +} + +function extractTextContent(content: unknown): string { + if (typeof content === "string") return content.trim(); + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === "string") return part; + if (part && typeof part === "object" && "text" in part) { + const text = (part as { text: unknown }).text; + return typeof text === "string" ? text : ""; + } + return ""; + }) + .join("") + .trim(); + } + return ""; +} + +function extractCookieValue(cookie: string, name: string): string | null { + const pairs = cookie.split(";"); + for (const pair of pairs) { + const [k, ...rest] = pair.trim().split("="); + if (k === name) return rest.join("="); + } + return null; +} + +/** + * Parse a Gemini Business entry URL into base origin + path prefix. + * Example: + * entryUrl = "https://business.gemini.google/home/cid/8888a888-b6e0-..." + * baseOrigin = "https://business.gemini.google" + * pathPrefix = "/home/cid/8888a888-b6e0-..." + * + * Also accepts protocol-less URLs like "business.gemini.google/home/cid/...", + * which users commonly paste from the browser address bar. + */ +function parseEntryUrl(entryUrl: string): { baseOrigin: string; pathPrefix: string } { + const fallback = { baseOrigin: "https://business.gemini.google", pathPrefix: "/home" }; + const trimmed = entryUrl.trim(); + if (!trimmed) return fallback; + + // Prepend https:// if the user pasted a protocol-less URL + const normalized = /^[a-z]+:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + + try { + const u = new URL(normalized); + if (!u.host) return fallback; + return { + baseOrigin: `${u.protocol}//${u.host}`, + pathPrefix: u.pathname.replace(/\/$/, "") || "/", + }; + } catch { + return fallback; + } +} + +/** + * Compute the SAPISID hash auth header value. + * Format: SAPISIDHASH {epoch_seconds}_{sha1_hash} + * The hash is sha1(epoch + " " + sapisid + " " + origin). + * See: https://developers.google.com/youtube/v3/guides/auth/server-side-apps + */ +function computeSapisidHash(sapisid: string, origin: string): string { + const epoch = Math.floor(Date.now() / 1000); + const hashInput = `${epoch} ${sapisid} ${origin}`; + const hash = createHash("sha1").update(hashInput).digest("hex"); + return `SAPISIDHASH ${epoch}_${hash}`; +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index f67c2f63e11b..2379eb6e2220 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -19,6 +19,7 @@ import { NineRouterExecutor } from "./ninerouter.ts"; import { PerplexityWebExecutor } from "./perplexity-web.ts"; import { GrokWebExecutor } from "./grok-web.ts"; import { GeminiWebExecutor } from "./gemini-web.ts"; +import { GeminiBusinessExecutor } from "./gemini-business.ts"; import { ChatGptWebExecutor } from "./chatgpt-web.ts"; import { BlackboxWebExecutor } from "./blackbox-web.ts"; import { MuseSparkWebExecutor } from "./muse-spark-web.ts"; @@ -95,6 +96,8 @@ const executors = { "cw-web": new ClaudeWebWithAutoRefresh(), // Alias "gemini-web": new GeminiWebExecutor(), gweb: new GeminiWebExecutor(), // Alias + "gemini-business": new GeminiBusinessExecutor(), + gembiz: new GeminiBusinessExecutor(), // Alias "chatgpt-web": new ChatGptWebExecutor(), "cgpt-web": new ChatGptWebExecutor(), // Alias "blackbox-web": new BlackboxWebExecutor(), diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index b937becf804b..402cfad60db6 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -587,6 +587,20 @@ export const WEB_COOKIE_PROVIDERS = { "Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → " + 'copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token).', }, + "gemini-business": { + id: "gemini-business", + alias: "gembiz", + name: "Gemini Business (Enterprise)", + icon: "business_center", + color: "#4285F4", + textIcon: "GB", + website: "https://business.gemini.google", + hasFree: true, + freeNote: + "Free for Google Workspace enterprise accounts — enterprise Gemini models (Pro, Flash, image, video) via direct StreamGenerate HTTP API. No subscription required, just enterprise SSO.", + authHint: + "From your enterprise account: open business.gemini.google/home/cid/{your-cid}, then copy __Secure-1PSID and __Secure-1PSIDTS cookies from DevTools → Application → Cookies. Paste as a cookie header below.", + }, }; // API Key Providers diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index f3f159c55473..4f9bfec84c9b 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -38,6 +38,13 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { acceptsFullCookieHeader: true, storageKeys: ["cookie", "__Secure-1PSID", "__Secure-1PSIDTS"], }, + "gemini-business": { + kind: "cookie", + credentialName: "__Secure-1PSID (optional: __Secure-1PSIDTS)", + placeholder: "__Secure-1PSID=...; __Secure-1PSIDTS=... (from business.gemini.google)", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "__Secure-1PSID", "__Secure-1PSIDTS"], + }, "perplexity-web": { kind: "cookie", credentialName: "__Secure-next-auth.session-token", diff --git a/tests/unit/gemini-business-provider.test.ts b/tests/unit/gemini-business-provider.test.ts new file mode 100644 index 000000000000..b87d96bda061 --- /dev/null +++ b/tests/unit/gemini-business-provider.test.ts @@ -0,0 +1,146 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +const { WEB_COOKIE_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); +const { WEB_SESSION_CREDENTIAL_REQUIREMENTS } = await import( + "../../src/shared/providers/webSessionCredentials.ts" +); +const { GeminiBusinessExecutor, parseStreamResponse } = await import( + "../../open-sse/executors/gemini-business.ts" +); + +// ─── Provider metadata ────────────────────────────────────────────────────── + +test("Gemini Business is registered in WEB_COOKIE_PROVIDERS with the canonical identity", () => { + const gem = WEB_COOKIE_PROVIDERS["gemini-business"]; + assert.ok(gem, "WEB_COOKIE_PROVIDERS.gemini-business must be defined"); + assert.equal(gem.id, "gemini-business"); + assert.equal(gem.alias, "gembiz"); + assert.equal(gem.name, "Gemini Business (Enterprise)"); + assert.equal(gem.website, "https://business.gemini.google"); + assert.equal(gem.hasFree, true); + assert.equal(typeof gem.textIcon, "string"); +}); + +test("Gemini Business credential requirements use __Secure-1PSID cookies", () => { + const req = WEB_SESSION_CREDENTIAL_REQUIREMENTS["gemini-business"]; + assert.ok(req, "credential requirements must be defined"); + assert.equal(req.kind, "cookie"); + assert.equal(req.acceptsFullCookieHeader, true); + assert.ok( + req.storageKeys.includes("__Secure-1PSID"), + "storageKeys must include __Secure-1PSID" + ); + assert.ok( + req.storageKeys.includes("__Secure-1PSIDTS"), + "storageKeys must include __Secure-1PSIDTS" + ); + assert.ok( + req.placeholder.includes("business.gemini.google"), + "placeholder must reference business.gemini.google" + ); +}); + +// ─── Executor class ───────────────────────────────────────────────────────── + +test("GeminiBusinessExecutor constructs with the correct provider", () => { + const ex = new GeminiBusinessExecutor(); + assert.equal((ex as unknown as { provider: string }).provider, "gemini-business"); +}); + +test("GeminiBusinessExecutor.execute returns 401 when no cookies are provided", async () => { + const ex = new GeminiBusinessExecutor(); + const result = await ex.execute({ + model: "gemini-2.5-pro", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: false, + credentials: {}, + signal: new AbortController().signal, + }); + assert.equal(result.response.status, 401); + const text = await result.response.text(); + assert.ok(text.includes("Missing Gemini Business cookies")); +}); + +test("GeminiBusinessExecutor.execute returns 400 when no user message is provided", async () => { + const ex = new GeminiBusinessExecutor(); + const result = await ex.execute({ + model: "gemini-2.5-pro", + body: { messages: [] }, + stream: false, + credentials: { apiKey: "__Secure-1PSID=fake; __Secure-1PSIDTS=fake" }, + signal: new AbortController().signal, + }); + assert.equal(result.response.status, 400); + const text = await result.response.text(); + assert.ok(text.includes("No user message found")); +}); + +// ─── parseStreamResponse ──────────────────────────────────────────────────── + +test("parseStreamResponse extracts text from a single wrb.fr chunk", () => { + // Real format per gemini-web2api.py: inner[4][0][1] is the text array. + // inner[4][0] is a 2-element pair [metadata, text_list]. + const inner = new Array(80).fill(null); + inner[4] = [[null, ["Hello, world!"]]]; + const innerStr = JSON.stringify(inner); + const raw = `[["wrb.fr", null, ${JSON.stringify(innerStr)}]]`; + const result = parseStreamResponse(raw); + assert.equal(result, "Hello, world!"); +}); + +test("parseStreamResponse concatenates text from multiple wrb.fr chunks", () => { + const makeChunk = (text: string) => { + const inner = new Array(80).fill(null); + inner[4] = [[null, [text]]]; + return `[["wrb.fr", null, ${JSON.stringify(JSON.stringify(inner))}]]`; + }; + const raw = `)]}'\n10\n${makeChunk("First ")}\n5\n${makeChunk("chunk")}`; + const result = parseStreamResponse(raw); + assert.equal(result, "First chunk"); +}); + +test("parseStreamResponse filters out non-string entries and ignores text-metadata slot", () => { + // Slot 0 of inner[4][0] is metadata (often null/empty), slot 1 is the text list. + const inner = new Array(80).fill(null); + inner[4] = [[null, ["clean ", 123, null, "text"]]]; + const innerStr = JSON.stringify(inner); + const raw = `[["wrb.fr", null, ${JSON.stringify(innerStr)}]]`; + const result = parseStreamResponse(raw); + assert.equal(result, "clean text"); +}); + +test("parseStreamResponse returns empty string for malformed response", () => { + const result = parseStreamResponse(")]}'\n42\nnot json"); + assert.equal(result, ""); +}); + +test("parseStreamResponse returns empty string for empty response", () => { + const result = parseStreamResponse(""); + assert.equal(result, ""); +}); + +test("parseStreamResponse returns empty string for non-wrb.fr lines", () => { + const raw = `)]}'\n10\n[["other.rpc", null, "irrelevant"]]`; + const result = parseStreamResponse(raw); + assert.equal(result, ""); +}); + +test("parseStreamResponse handles inner array with empty text", () => { + const inner = new Array(80).fill(null); + inner[4] = [[""]]; + const innerStr = JSON.stringify(inner); + const raw = `[["wrb.fr", null, ${JSON.stringify(innerStr)}]]`; + const result = parseStreamResponse(raw); + assert.equal(result, ""); +}); + +test("parseStreamResponse handles real Gemini response format with multiple inner array slots", () => { + const inner = new Array(80).fill(null); + inner[4] = [[null, ["This is the real format."]]]; + const innerStr = JSON.stringify(inner); + const raw = `[["wrb.fr", null, ${JSON.stringify(innerStr)}]]`; + const result = parseStreamResponse(raw); + assert.equal(result, "This is the real format."); +}); From 3c98e9f1ef6c81cd44f9f6f20687f4df8ddea749 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Mon, 8 Jun 2026 23:43:50 +0200 Subject: [PATCH 14/34] fix: probe container bridge network IP in healthcheck (#3151) (#3434) Integrated into release/v3.8.17 --- scripts/dev/healthcheck.mjs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/scripts/dev/healthcheck.mjs b/scripts/dev/healthcheck.mjs index c54e876e27e5..56acaae205b1 100644 --- a/scripts/dev/healthcheck.mjs +++ b/scripts/dev/healthcheck.mjs @@ -11,13 +11,36 @@ * error, so the container was reported `unhealthy` with an empty, undiagnosable * `State.Health[].Output`. We now try an ordered list of hosts and surface the * last error on total failure. + * + * Bridge Network Fix: Also probes the container's internal bridge IP (e.g., 172.17.0.2) + * to handle Docker network setups that isolate loopback interfaces. */ import { pathToFileURL } from "node:url"; +import { networkInterfaces } from "node:os"; const DEFAULT_HOSTS = ["127.0.0.1", "localhost", "::1"]; const DEFAULT_TIMEOUT_MS = 4000; +/** + * Get the primary non-loopback IPv4 address (container internal IP). + * Falls back to null if unable to determine. + */ +function getContainerInternalIP() { + try { + const interfaces = networkInterfaces(); + for (const [name, addrs] of Object.entries(interfaces)) { + // Skip loopback and docker0, prioritize eth0/veth interfaces + if (name.startsWith("lo") || name === "docker0") continue; + const ipv4 = addrs?.find((a) => a.family === "IPv4" && !a.internal); + if (ipv4) return ipv4.address; + } + } catch { + // silently ignore if unable to read interfaces + } + return null; +} + /** * Build the health URL for a host, bracketing IPv6 literals (e.g. `::1`). * @param {string} host @@ -64,8 +87,16 @@ export async function probeHealth({ async function main() { const port = process.env.DASHBOARD_PORT || process.env.PORT || "20128"; + + // Build host list: defaults + detected container bridge IP + const hosts = [...DEFAULT_HOSTS]; + const containerIP = getContainerInternalIP(); + if (containerIP && !hosts.includes(containerIP)) { + hosts.push(containerIP); + } + try { - await probeHealth({ port }); + await probeHealth({ port, hosts }); process.exit(0); } catch (err) { // Surface the failure so `docker inspect ... .State.Health[].Output` is From 858b6742e8981ce6c5d9468555d26a6c71d8a6cc Mon Sep 17 00:00:00 2001 From: Hernan Javier Ardila Sanchez Date: Mon, 8 Jun 2026 23:44:13 +0200 Subject: [PATCH 15/34] fix(publish): remove onnxruntime CUDA binary from tarball to avoid 413 (#3437) Integrated into release/v3.8.17 --- scripts/build/pack-artifact-policy.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 3a8953457282..11939e660ee8 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -25,6 +25,10 @@ const STAGING_FORBIDDEN_FILES = ["audit-report.json", "package-lock.json"]; export const APP_STAGING_REMOVAL_PATHS: string[] = [ ...STAGING_FORBIDDEN_DIRECTORIES, ...STAGING_FORBIDDEN_FILES, + // onnxruntime CUDA provider binary (~316 MB) inflates the npm tarball + // past the registry 413 limit for npm.org. It's only needed on systems + // with a CUDA GPU — users install CUDA providers separately. + "node_modules/onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime_providers_cuda.so", ]; export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ From 96e5ec9269ee96bd23b6ee2decf3e87e4a223d85 Mon Sep 17 00:00:00 2001 From: Hernan Javier Ardila Sanchez Date: Mon, 8 Jun 2026 23:44:30 +0200 Subject: [PATCH 16/34] =?UTF-8?q?docs(opencode-plugin):=20lead=20with=20th?= =?UTF-8?q?e=20why=20=E2=80=94=20make=20plugin=20the=20recommended=20path?= =?UTF-8?q?=20over=20@omniroute/opencode-provider=20(#3418)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.17 --- @omniroute/opencode-plugin/README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/@omniroute/opencode-plugin/README.md b/@omniroute/opencode-plugin/README.md index 73cad32efd23..f5d52c511d21 100644 --- a/@omniroute/opencode-plugin/README.md +++ b/@omniroute/opencode-plugin/README.md @@ -1,6 +1,20 @@ # @omniroute/opencode-plugin -First-class OpenCode plugin for the [OmniRoute AI Gateway](https://github.com/diegosouzapw/OmniRoute). Pulls a live model catalog from `/v1/models` (including `-low`/`-medium`/`-high`/`-thinking` variants as first-class IDs), aggregates combos via `/api/combos` using a least-common-denominator capability/limit join, sanitizes Gemini tool schemas in flight, and supports multiple side-by-side OmniRoute instances out of the box. +> **Recommended way to use OmniRoute with OpenCode.** Pulls a live model catalog from `/v1/models` (including `-low`/`-medium`/`-high`/`-thinking` variants as first-class IDs), aggregates combos via `/api/combos` using a least-common-denominator capability/limit join, sanitizes Gemini tool schemas in flight, and supports multiple side-by-side OmniRoute instances out of the box. + +## Why this and not `@omniroute/opencode-provider`? + +`@omniroute/opencode-provider` is the legacy config-generator package — it writes a frozen `provider.omniroute` block into `opencode.json` with a **hardcoded list of 8 models** ([`OMNIROUTE_DEFAULT_OPENCODE_MODELS`](https://github.com/diegosouzapw/OmniRoute/blob/main/%40omniroute/opencode-provider/src/index.ts#L48-L56)). It works on the CLI but in the **OpenCode Desktop / Web** builds (Tauri / Electron) the runtime re-runs the model picker and the static block surfaces only a few of those — and they drift behind the live OmniRoute catalog. + +This plugin solves that by: + +- Fetching `/v1/models` and `/api/combos` **at OpenCode startup, in Node.js** — no CORS, no WebView restrictions +- Emitting the provider block **dynamically** in the plugin's `config`/`provider` hook — so `opencode.json` only needs the plugin entry, not a static `provider.omniroute` +- Re-fetching on a configurable TTL (default 5 min), so new models / combo changes in the OmniRoute UI appear without restarting OpenCode +- Computing `limit.context` for combos as `min(member.context_length)` from the live catalog (no more `null` values that cause 4K-token truncation) +- **Auto-pickup of `interleaved` capability** for thinking models (merged via PR #3138) + +**If you only have the legacy `opencode-provider` block in your `opencode.json`, replace it with a single plugin entry.** No other config changes required — the same `auth.json` API key works. ## Install From a00366602bbeda2fac07fc81fcf515c6e794f27f Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:44:48 +0700 Subject: [PATCH 17/34] docs: close critical documentation gaps (ACP, router strategies, APIs, compression) (#3438) Integrated into release/v3.8.17 --- .source/source.config.mjs | 1 + docs/DOCUMENTATION_OVERHAUL_PLAN.md | 334 ++++++++++++++ docs/README.md | 21 +- docs/compression/COMPRESSION_GUIDE.md | 200 ++++++++ docs/frameworks/ACP.md | 560 +++++++++++++++++++++++ docs/frameworks/meta.json | 1 + docs/getting-started/AUTO-COMBO-GUIDE.md | 210 +++++++++ docs/getting-started/FREE-TIERS-GUIDE.md | 270 +++++++++++ docs/getting-started/PROVIDERS-GUIDE.md | 220 +++++++++ docs/getting-started/QUICK-START.md | 135 ++++++ docs/getting-started/TROUBLESHOOTING.md | 501 ++++++++++++++++++++ docs/getting-started/meta.json | 11 + docs/guides/TROUBLESHOOTING.md | 26 ++ docs/guides/meta.json | 2 +- docs/meta.json | 16 +- docs/reference/API_REFERENCE.md | 335 ++++++++++++++ docs/reference/FREE_TIERS.md | 2 + docs/reference/PROVIDER_REFERENCE.md | 2 + docs/routing/AUTO-COMBO.md | 241 +++++++++- src/app/docs/page.tsx | 58 ++- 20 files changed, 3103 insertions(+), 43 deletions(-) create mode 100644 docs/DOCUMENTATION_OVERHAUL_PLAN.md create mode 100644 docs/frameworks/ACP.md create mode 100644 docs/getting-started/AUTO-COMBO-GUIDE.md create mode 100644 docs/getting-started/FREE-TIERS-GUIDE.md create mode 100644 docs/getting-started/PROVIDERS-GUIDE.md create mode 100644 docs/getting-started/QUICK-START.md create mode 100644 docs/getting-started/TROUBLESHOOTING.md create mode 100644 docs/getting-started/meta.json diff --git a/.source/source.config.mjs b/.source/source.config.mjs index 1b0644fe1ef5..8405202c5562 100644 --- a/.source/source.config.mjs +++ b/.source/source.config.mjs @@ -4,6 +4,7 @@ var docs = defineDocs({ dir: "docs", docs: { files: [ + "./getting-started/**/*.md", "./architecture/**/*.md", "./guides/**/*.md", "./reference/**/*.md", diff --git a/docs/DOCUMENTATION_OVERHAUL_PLAN.md b/docs/DOCUMENTATION_OVERHAUL_PLAN.md new file mode 100644 index 000000000000..6e95ee67abe9 --- /dev/null +++ b/docs/DOCUMENTATION_OVERHAUL_PLAN.md @@ -0,0 +1,334 @@ +# OmniRoute Documentation Overhaul Plan + +> Goal: Make all docs accessible to non-tech users while preserving technical depth for developers. +> Strategy: Two-tier docs (User Guide + Technical Reference) with cross-links. + +--- + +## Design Principles + +1. **User Guide** (new files in `docs/guides/`) + - Plain language, no jargon + - "Why → What → How" structure + - Tables for comparisons + - Copy-paste examples + - "What's next?" at the end + +2. **Technical Reference** (existing files) + - Keep as-is (already well-written) + - Add "For Users" callout at top linking to user guide + - Add "Prerequisites" section + - Ensure consistent formatting + +3. **Cross-Links** + - User guides link to technical docs for "learn more" + - Technical docs link to user guides for "quick start" + +--- + +## Phase 1: User-Facing Quick Start Docs (Highest Impact) + +### 1.1 `docs/guides/QUICK-START.md` — NEW +**Replaces**: Condensed version of README.md +**Content**: +- Install (npm, Docker, source) +- Connect a free provider (3 steps) +- Point your IDE to OmniRoute +- Verify it works +- "What's next?" → link to other guides + +### 1.2 `docs/guides/AUTO-COMBO-GUIDE.md` — NEW +**Complements**: `docs/routing/AUTO-COMBO.md` +**Content**: +- What auto-combo does (30-second version) +- Which `auto` should I use? (table) +- How it picks providers (simple version) +- Common questions (FAQ) +- "Learn more" → link to technical reference + +### 1.3 `docs/guides/PROVIDERS-GUIDE.md` — NEW +**Complements**: `docs/reference/PROVIDER_REFERENCE.md` +**Content**: +- What is a provider? (analogy) +- How to connect a provider (step-by-step) +- Free vs paid providers (table) +- OAuth vs API key (which do I need?) +- Troubleshooting connection issues +- "Learn more" → link to provider reference + +### 1.4 `docs/guides/FREE-TIERS-GUIDE.md` — NEW +**Complements**: `docs/reference/FREE_TIERS.md` +**Content**: +- What are free tiers? (analogy) +- Best free providers (table with quotas) +- How to stack free tiers for unlimited usage +- Common questions (FAQ) +- "Learn more" → link to free tiers reference + +### 1.5 `docs/guides/TROUBLESHOOTING.md` — REWRITE +**Current**: Technical, code-heavy +**New**: "I see X → Do Y" format +**Content**: +- Problem → Solution table +- Common error messages (with screenshots) +- "Still stuck?" → link to community + +--- + +## Phase 2: Refine Existing User Docs + +### 2.1 `docs/guides/USER_GUIDE.md` — UPDATE +**Changes**: +- Add "What you'll learn" section at top +- Remove jargon, explain terms +- Add step-by-step screenshots +- Add "Common mistakes" section + +### 2.2 `docs/guides/SETUP_GUIDE.md` — UPDATE +**Changes**: +- Add "Prerequisites" section +- Simplify commands (one-liners) +- Add "Verify it works" step +- Add "Troubleshooting" section + +### 2.3 `docs/guides/FEATURES.md` — UPDATE +**Changes**: +- Add "Why this matters" for each feature +- Add use-case examples +- Add "How to enable" for each feature +- Add screenshots + +### 2.4 `docs/guides/DOCKER_GUIDE.md` — UPDATE +**Changes**: +- Add "Docker for beginners" section +- Simplify commands +- Add "Common Docker issues" section +- Add "What's next?" section + +### 2.5 `docs/guides/ELECTRON_GUIDE.md` — UPDATE +**Changes**: +- Add "What is Electron?" explanation +- Add screenshots +- Add "Common issues" section + +### 2.6 `docs/guides/TERMUX_GUIDE.md` — UPDATE +**Changes**: +- Add "What is Termux?" explanation +- Add step-by-step with screenshots +- Add "Common issues" section + +### 2.7 `docs/guides/PWA_GUIDE.md` — UPDATE +**Changes**: +- Add "What is a PWA?" explanation +- Add step-by-step with screenshots +- Add "Common issues" section + +### 2.8 `docs/guides/I18N.md` — UPDATE +**Changes**: +- Add "How to change language" (user perspective) +- Add "How to contribute translations" (contributor perspective) + +### 2.9 `docs/guides/KIRO_SETUP.md` — UPDATE +**Changes**: +- Add "What is Kiro?" explanation +- Add step-by-step with screenshots +- Add "Common issues" section + +### 2.10 `docs/guides/UNINSTALL.md` — UPDATE +**Changes**: +- Add "Why uninstall?" section (common reasons) +- Add "Before you uninstall" checklist +- Simplify commands + +--- + +## Phase 3: Technical Docs (Add Cross-Links, Keep As-Is) + +### 3.1 `docs/architecture/` (6 files) +**Changes**: Add "For Users" callout at top → link to user guide +- ARCHITECTURE.md +- AUTHZ_GUIDE.md +- CODEBASE_DOCUMENTATION.md +- MONITORING_SECTIONS.md +- REPOSITORY_MAP.md +- RESILIENCE_GUIDE.md + +### 3.2 `docs/frameworks/` (16 files) +**Changes**: Add "For Users" callout at top → link to user guide +- A2A-SERVER.md +- AGENT-SKILLS.md +- AGENTBRIDGE.md +- AGENT_PROTOCOLS_GUIDE.md +- CLOUD_AGENT.md +- EMBEDDED-SERVICES.md +- EVALS.md +- GAMIFICATION.md +- MCP-SERVER.md +- MEMORY.md +- OPENCODE.md +- PLAYGROUND_STUDIO.md +- SEARCH_TOOLS_STUDIO.md +- SKILLS.md +- TRAFFIC_INSPECTOR.md +- WEBHOOKS.md + +### 3.3 `docs/security/` (9 files) +**Changes**: Add "For Users" callout at top → link to user guide +- CLI_TOKEN.md +- CLI_TOKEN_AUTH.md +- COMPLIANCE.md +- ERROR_SANITIZATION.md +- GUARDRAILS.md +- PUBLIC_CREDS.md +- ROUTE_GUARD_TIERS.md +- SOCKET_DEV_FINDINGS.md +- STEALTH_GUIDE.md + +### 3.4 `docs/ops/` (8 files) +**Changes**: Add "For Users" callout at top → link to user guide +- COVERAGE_PLAN.md +- E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md +- FLY_IO_DEPLOYMENT_GUIDE.md +- PROXY_GUIDE.md +- RELEASE_CHECKLIST.md +- SQLITE_RUNTIME.md +- TUNNELS_GUIDE.md +- VM_DEPLOYMENT_GUIDE.md + +### 3.5 `docs/compression/` (5 files) +**Changes**: Add "For Users" callout at top → link to user guide +- COMPRESSION_ENGINES.md +- COMPRESSION_GUIDE.md +- COMPRESSION_LANGUAGE_PACKS.md +- COMPRESSION_RULES_FORMAT.md +- RTK_COMPRESSION.md + +### 3.6 `docs/routing/` (3 files) +**Changes**: Add "For Users" callout at top → link to user guide +- AUTO-COMBO.md (→ link to AUTO-COMBO-GUIDE.md) +- QUOTA_SHARE.md +- REASONING_REPLAY.md + +### 3.7 `docs/reference/` (5 files) +**Changes**: Add "For Users" callout at top → link to user guide +- API_REFERENCE.md +- CLI-TOOLS.md +- ENVIRONMENT.md +- FREE_TIERS.md (→ link to FREE-TIERS-GUIDE.md) +- PROVIDER_REFERENCE.md (→ link to PROVIDERS-GUIDE.md) + +### 3.8 Other Docs (keep as-is) +- `docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md` — Already user-friendly +- `docs/marketing/TIERS.md` — Already user-friendly +- `docs/diagrams/README.md` — Keep as-is +- `docs/dev/plugins.md` — Developer-only +- `docs/plugins/PLUGIN_SDK.md` — Developer-only +- `docs/providers/ZED-DOCKER.md` — Provider-specific +- `docs/AGENTROUTER.md` — Provider-specific +- `docs/PROVIDERS.md` — Provider-specific +- `docs/README.md` — Keep as-is +- `docs/SUBMIT_PR.md` — Contributor-only +- `docs/releases/v3.8.0.md` — Release notes +- `docs/research/` — Internal research +- `docs/specs/` — Internal specs +- `docs/openspec/` — Internal specs +- `docs/superpowers/` — Internal plans + +--- + +## Phase 4: Update Main README.md + +**Changes**: +- Add "Quick Start" section (3 steps) +- Add "Which `auto` should I use?" table +- Add "Free providers" table +- Add "Common questions" section +- Link to user guides + +--- + +## Execution Order + +### Week 1: Phase 1 (User-Facing Quick Start Docs) +1. `docs/guides/QUICK-START.md` — NEW +2. `docs/guides/AUTO-COMBO-GUIDE.md` — NEW +3. `docs/guides/PROVIDERS-GUIDE.md` — NEW +4. `docs/guides/FREE-TIERS-GUIDE.md` — NEW +5. `docs/guides/TROUBLESHOOTING.md` — REWRITE + +### Week 2: Phase 2 (Refine Existing User Docs) +1. `docs/guides/USER_GUIDE.md` — UPDATE +2. `docs/guides/SETUP_GUIDE.md` — UPDATE +3. `docs/guides/FEATURES.md` — UPDATE +4. `docs/guides/DOCKER_GUIDE.md` — UPDATE +5. `docs/guides/ELECTRON_GUIDE.md` — UPDATE +6. `docs/guides/TERMUX_GUIDE.md` — UPDATE +7. `docs/guides/PWA_GUIDE.md` — UPDATE +8. `docs/guides/I18N.md` — UPDATE +9. `docs/guides/KIRO_SETUP.md` — UPDATE +10. `docs/guides/UNINSTALL.md` — UPDATE + +### Week 3: Phase 3 (Technical Docs Cross-Links) +1. `docs/architecture/` — Add cross-links +2. `docs/frameworks/` — Add cross-links +3. `docs/security/` — Add cross-links +4. `docs/ops/` — Add cross-links +5. `docs/compression/` — Add cross-links +6. `docs/routing/` — Add cross-links +7. `docs/reference/` — Add cross-links + +### Week 4: Phase 4 (Update Main README.md) +1. Update README.md with user-friendly sections + +--- + +## Success Metrics + +- [ ] User can install OmniRoute in < 5 minutes (QUICK-START.md) +- [ ] User can connect a provider in < 3 minutes (PROVIDERS-GUIDE.md) +- [ ] User can use auto-combo in < 1 minute (AUTO-COMBO-GUIDE.md) +- [ ] User can find free providers in < 2 minutes (FREE-TIERS-GUIDE.md) +- [ ] User can troubleshoot common issues in < 5 minutes (TROUBLESHOOTING.md) +- [ ] All technical docs link to user guides +- [ ] All user guides link to technical docs + +--- + +## File Naming Convention + +- User guides: `docs/guides/[FEATURE]-GUIDE.md` (e.g., `AUTO-COMBO-GUIDE.md`) +- Technical docs: Keep existing names (e.g., `docs/routing/AUTO-COMBO.md`) +- Cross-links: "For Users" callout at top of technical docs + +--- + +## Template for User Guides + +```markdown +# [Feature Name]: [One-Line Description] + +## What It Does +[2-3 sentences explaining what the feature does in plain language] + +## Quick Start +[Step-by-step instructions to get started] + +## [Main Section] +[Detailed explanation with tables, examples, screenshots] + +## Common Questions +[FAQ section with common questions and answers] + +## What's Next? +[Links to related guides and technical docs] +``` + +--- + +## Template for Technical Doc Cross-Links + +```markdown +> **For Users**: Looking for a quick start? See the [User Guide](../guides/[FEATURE]-GUIDE.md). + +> **Prerequisites**: [List prerequisites] +``` diff --git a/docs/README.md b/docs/README.md index 26204864dc79..b69e0ac9a625 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,9 +22,19 @@ How the system is put together — read these to understand the runtime, code la - [AUTHZ_GUIDE.md](architecture/AUTHZ_GUIDE.md) — authorization pipeline (route classifier + policy engine). - [RESILIENCE_GUIDE.md](architecture/RESILIENCE_GUIDE.md) — provider circuit breaker, connection cooldown, and model lockout. -## guides/ +## For Non-Tech Users -Task-focused walkthroughs for operators and end users. +Simple guides for using OmniRoute — no technical background needed. + +### getting-started/ + +- [QUICK-START.md](getting-started/QUICK-START.md) — install and run OmniRoute in 3 minutes. +- [AUTO-COMBO-GUIDE.md](getting-started/AUTO-COMBO-GUIDE.md) — let OmniRoute pick the best AI for you. +- [PROVIDERS-GUIDE.md](getting-started/PROVIDERS-GUIDE.md) — how to connect AI providers. +- [FREE-TIERS-GUIDE.md](getting-started/FREE-TIERS-GUIDE.md) — get free AI with no credit card. +- [TROUBLESHOOTING.md](getting-started/TROUBLESHOOTING.md) — fix common issues. + +### guides/ - [SETUP_GUIDE.md](guides/SETUP_GUIDE.md) — first-time setup of OmniRoute. - [USER_GUIDE.md](guides/USER_GUIDE.md) — daily usage of the dashboard and API. @@ -32,12 +42,15 @@ Task-focused walkthroughs for operators and end users. - [ELECTRON_GUIDE.md](guides/ELECTRON_GUIDE.md) — desktop (Electron) builds. - [TERMUX_GUIDE.md](guides/TERMUX_GUIDE.md) — running on Android via Termux. - [PWA_GUIDE.md](guides/PWA_GUIDE.md) — installing the dashboard as a PWA. -- [TROUBLESHOOTING.md](guides/TROUBLESHOOTING.md) — common issues and fixes. - [UNINSTALL.md](guides/UNINSTALL.md) — clean removal steps. - [I18N.md](guides/I18N.md) — translation and locale workflow. - [FEATURES.md](guides/FEATURES.md) — dashboard feature gallery. -## reference/ +## For Tech Users + +Technical documentation for developers and contributors. + +## architecture/ Lookup material — API surface, environment variables, CLI flags, provider catalog. diff --git a/docs/compression/COMPRESSION_GUIDE.md b/docs/compression/COMPRESSION_GUIDE.md index d3f169e65e7d..3cc74a8e3bf7 100644 --- a/docs/compression/COMPRESSION_GUIDE.md +++ b/docs/compression/COMPRESSION_GUIDE.md @@ -271,6 +271,206 @@ RTK mode is inspired by **[RTK - Rust Token Killer](https://github.com/rtk-ai/rt --- +## Advanced Compression Systems + +Beyond the 7 standard modes, OmniRoute includes several advanced compression +systems that work automatically based on context. + +### Cache-Aware Compression + +Some providers (like Anthropic with prompt caching) support **prompt caching**, +which lets them cache parts of the prompt to reduce costs and latency. When +caching is enabled, aggressive compression can actually **hurt** performance +because it changes the cached tokens, invalidating the cache. + +The `cachingAware.ts` module solves this by **detecting caching context** and +**adjusting the compression strategy** accordingly. + +#### How it works + +1. **Detect caching context** — Scans the request body for `cache_control` markers +2. **Identify caching providers** — Checks if the target provider supports caching +3. **Adjust strategy** — Downgrades `aggressive`/`ultra` to `standard` for caching providers +4. **Skip system prompt** — System prompts are usually cached, so don't compress them +5. **Use deterministic transformations** — Only use transformations that produce consistent output + +#### Code example + +```ts +import { detectCachingContext, getCacheAwareStrategy } from "@omniroute/open-sse/services/compression/cachingAware"; + +const body = { + model: "anthropic/claude-sonnet-4.5", + messages: [{ role: "user", content: "Hello" }], + cache_control: { type: "ephemeral" }, // ← Cache marker +}; + +const ctx = detectCachingContext(body, { provider: "anthropic" }); +// → { hasCacheControl: true, provider: "anthropic", isCachingProvider: true } + +const strategy = getCacheAwareStrategy("aggressive", ctx); +// → { strategy: "standard", skipSystemPrompt: true, deterministicOnly: true } +``` + +#### When to use + +Cache-aware compression is **always on** — no configuration needed. It only kicks in +when: +- The request has `cache_control` markers +- The target provider supports prompt caching (Anthropic, OpenAI, etc.) + +### Progressive Aging + +Long conversations accumulate many message turns, but older turns become less +relevant. The `progressiveAging.ts` module **degrades messages by turn distance**: + +- **Recent turns (0-3)**: Kept verbatim (full detail) +- **Medium turns (4-8)**: Lite compression (whitespace, formatting cleanup) +- **Old turns (9+)**: Caveman compression (filler removal, summarization) +- **Very old turns (20+)**: Heavily summarized or dropped + +#### Code example + +```ts +import { applyAging } from "@omniroute/open-sse/services/compression/progressiveAging"; + +const messages = [ + { role: "system", content: "You are a helpful assistant" }, + { role: "user", content: "What is 2+2?" }, + { role: "assistant", content: "4" }, + // ... 50 more turns ... +]; + +const { messages: aged, saved } = applyAging(messages, { + verbatim: 3, // First 3 turns: verbatim + light: 8, // Turns 4-8: lite compression + moderate: 20, // Turns 9-20: caveman compression + // Turns 21+: heavy summarization +}); + +// saved = number of tokens saved +``` + +#### When to use + +Progressive aging is **always on** for `aggressive` and `ultra` modes. It's +particularly effective for: +- Long-running coding sessions +- Multi-day conversations +- Agentic workflows with many tool calls + +### Caveman Output Mode + +The `outputMode.ts` module injects **system prompt instructions** to make the +model itself produce compressed, terse output (a "caveman" style). + +#### How it works + +Instead of compressing the input, this mode adds a system prompt like: +> "Reply in minimal words. Skip pleasantries. Use short sentences." + +This works particularly well for: +- Code generation (terser output = fewer tokens) +- Quick Q&A (no need for elaborate explanations) +- Batch processing (maximize throughput) + +#### When to use + +Caveman output mode is **opt-in** — set it via the combo config: +```json +{ + "strategy": "auto", + "config": { + "auto": { + "outputMode": "caveman" + } + } +} +``` + +### Tool Result Compression + +The `toolResultCompressor.ts` module provides **5 specialized compression strategies** +for tool results (function calls, agent outputs, search results, etc.): + +1. **Search result compression** — Removes redundant results, keeps top-N +2. **File read compression** — Truncates large files, preserves headers/imports +3. **Code execution compression** — Keeps only essential stdout/stderr +4. **Database query compression** — Limits rows, removes verbose metadata +5. **API response compression** — Strips null fields, condenses arrays + +#### When to use + +Tool result compression is **always on** when tool calls are present. No +configuration needed. + +### Stacked Pipeline + +The stacked mode runs **multiple engines in sequence** — usually RTK first +(60-90% savings on tool output), then Caveman (30% additional savings on the +remaining text). This achieves **78-95% total savings**. + +#### How it works + +``` +Input (1000 tokens) + → RTK (command-aware filter) → 200 tokens + → Caveman (filler removal) → 140 tokens + → Output (140 tokens, 86% savings) +``` + +#### When to use + +Use stacked mode for: +- Tool-heavy workflows (agentic coding, research) +- Cost-sensitive batch processing +- When you need maximum token savings + +Configure via combo: +```json +{ + "strategy": "auto", + "config": { + "auto": { + "modePack": "stacked" + } + } +} +``` + +--- + +## Compression Combo Overrides + +You can override the global compression mode **per combo** to fine-tune behavior +for different use cases: + +```json +{ + "id": "coding-combo", + "strategy": "priority", + "config": { + "auto": { + "weights": { "taskFit": 0.5 }, + "modePack": "quality-first" + } + }, + "compressionOverride": { + "mode": "aggressive", + "stackedPipelines": ["rtk", "caveman"], + "preserveToolDefinitions": true + } +} +``` + +This is useful for: +- **Coding combos**: Use `aggressive` mode for long sessions +- **Quick Q&A combos**: Use `lite` mode for fast responses +- **Tool-heavy combos**: Use `stacked` mode for max savings +- **Production combos**: Use `cache-aware` mode for caching providers + +--- + ## See Also - [Environment Config](../reference/ENVIRONMENT.md) — Compression environment variables diff --git a/docs/frameworks/ACP.md b/docs/frameworks/ACP.md new file mode 100644 index 000000000000..4807a5a0d5c1 --- /dev/null +++ b/docs/frameworks/ACP.md @@ -0,0 +1,560 @@ +# ACP (Agent Client Protocol) + +> **TL;DR**: ACP lets OmniRoute spawn CLI agents (like Claude Code, Codex, Gemini CLI) as child processes instead of using HTTP APIs. This gives you "CLI-as-backend" transport. + +--- + +## What Is ACP? + +ACP (Agent Client Protocol) is a **"CLI-as-backend" transport** for OmniRoute. Instead of intercepting HTTP API calls to AI providers, ACP **spawns CLI agents as child processes** and feeds prompts through their native interface. + +### Why Use ACP? + +| Benefit | Description | +|---------|-------------| +| **No API keys needed** | Uses your existing CLI authentication | +| **Native protocol** | Uses each CLI's native input/output format | +| **Auto-discovery** | Detects installed CLIs on your system | +| **14 built-in agents** | Pre-configured for popular CLI tools | +| **Custom agents** | Add your own CLI tools via settings | +| **Process management** | Handles lifecycle (spawn, send, kill) | + +--- + +## Supported CLI Agents + +ACP supports **14 built-in CLI agents** out of the box: + +| Agent ID | Display Name | Binary | Protocol | +|----------|--------------|--------|----------| +| `codex` | OpenAI Codex CLI | `codex` | stdio | +| `claude` | Claude Code CLI | `claude` | stdio | +| `goose` | Goose CLI | `goose` | stdio | +| `gemini-cli` | Gemini CLI | `gemini` | stdio | +| `openclaw` | OpenClaw | `openclaw` | stdio | +| `aider` | Aider | `aider` | stdio | +| `opencode` | OpenCode | `opencode` | stdio | +| `cline` | Cline | `cline` | stdio | +| `qwen-code` | Qwen Code | `qwen` | stdio | +| `forge` | ForgeCode | `forge` | stdio | +| `amazon-q` | Amazon Q Developer | `q` | stdio | +| `interpreter` | Open Interpreter | `interpreter` | stdio | +| `cursor-cli` | Cursor CLI | `cursor` | stdio | +| `warp` | Warp AI | `warp` | stdio | + +### Custom Agents + +You can add your own CLI agents via settings. Custom agents support the same features as built-in agents. + +--- + +## Quick Start + +### Step 1: Install a CLI Agent + +```bash +# Example: Install Claude Code CLI +npm install -g @anthropic-ai/claude-code + +# Verify installation +claude --version +``` + +### Step 2: ACP Auto-Detection + +ACP automatically detects installed CLI agents on your system. No configuration needed! + +### Step 3: Use ACP Transport + +Once detected, ACP can be used as a transport for any supported provider. OmniRoute will automatically use ACP when the CLI is available. + +--- + +## How ACP Works + +### Architecture + +``` +┌─────────────────┐ +│ OmniRoute │ +│ (HTTP Proxy) │ +└────────┬────────┘ + │ + │ spawn() + ▼ +┌─────────────────┐ +│ Child Process │ +│ (CLI Agent) │ +│ │ +│ stdin ◄──────┤ Send prompt +│ stdout ──────►│ Receive response +│ stderr ──────►│ Receive errors +└─────────────────┘ +``` + +### Process Lifecycle + +1. **Spawn** — ACP creates a child process for the CLI agent +2. **Send** — ACP writes prompts to the process's stdin +3. **Receive** — ACP reads responses from stdout/stderr +4. **Idle Detection** — ACP waits 2 seconds of inactivity before considering the response complete +5. **Kill** — ACP terminates the process (SIGTERM, then SIGKILL after 5s) + +### Communication Protocol + +ACP uses **stdio** (standard input/output) for communication with CLI agents. The protocol is: + +1. **Send prompt** — Write to stdin with a newline +2. **Wait for response** — Read from stdout until idle (2s of no output) +3. **Timeout** — Default 120 seconds (configurable) + +--- + +## API Reference + +### Registry Functions + +#### `detectInstalledAgents()` + +Detects all installed CLI agents on the system. Results are cached for 60 seconds. + +```typescript +import { detectInstalledAgents } from "@/lib/acp"; + +const agents = detectInstalledAgents(); +// Returns: CliAgentInfo[] + +interface CliAgentInfo { + id: string; // e.g., "codex", "claude" + name: string; // Display name + binary: string; // Binary name to spawn + versionCommand: string; // Version detection command + version: string | null; // Detected version (null if not installed) + installed: boolean; // Whether the agent is installed + providerAlias: string; // Provider ID in OmniRoute + spawnArgs: string[]; // Arguments to pass when spawning + protocol: "stdio" | "http"; // Communication protocol + isCustom?: boolean; // Whether this is a user-defined custom agent +} +``` + +#### `getAvailableAgents()` + +Gets only the agents that are installed and available for ACP. + +```typescript +import { getAvailableAgents } from "@/lib/acp"; + +const available = getAvailableAgents(); +// Returns: CliAgentInfo[] (only installed agents) +``` + +#### `getAgentById(id)` + +Gets a specific agent by ID. + +```typescript +import { getAgentById } from "@/lib/acp"; + +const agent = getAgentById("claude"); +// Returns: CliAgentInfo | undefined +``` + +#### `setCustomAgents(agents)` + +Sets custom agent definitions from settings. + +```typescript +import { setCustomAgents } from "@/lib/acp"; + +setCustomAgents([ + { + id: "my-custom-cli", + name: "My Custom CLI", + binary: "mycli", + versionCommand: "mycli --version", + providerAlias: "my-provider", + spawnArgs: [], + protocol: "stdio", + }, +]); +``` + +### Manager Functions + +#### `acpManager.spawn(agentId, binary, args, env)` + +Spawns a new CLI agent process. + +```typescript +import { acpManager } from "@/lib/acp"; + +const session = acpManager.spawn( + "claude", + "claude", + ["--print", "--output-format", "json"], + { /* custom env vars */ } +); +// Returns: AcpSession +``` + +**Allowed agent IDs**: `["claude", "codex", "gemini", "qwen"]` + +#### `acpManager.sendPrompt(sessionId, prompt, timeoutMs)` + +Sends a prompt to a CLI agent and collects the response. + +```typescript +import { acpManager } from "@/lib/acp"; + +const response = await acpManager.sendPrompt( + "acp-claude-1234567890-abc123", + "What is 2+2?", + 120000 // 2 minutes timeout +); +// Returns: Promise +``` + +#### `acpManager.kill(sessionId)` + +Kills a session and cleans up. + +```typescript +import { acpManager } from "@/lib/acp"; + +const killed = acpManager.kill("acp-claude-1234567890-abc123"); +// Returns: boolean +``` + +#### `acpManager.getActiveSessions()` + +Gets all active sessions. + +```typescript +import { acpManager } from "@/lib/acp"; + +const sessions = acpManager.getActiveSessions(); +// Returns: AcpSession[] +``` + +#### `acpManager.killAll()` + +Kills all sessions. + +```typescript +import { acpManager } from "@/lib/acp"; + +acpManager.killAll(); +``` + +### Session Interface + +```typescript +interface AcpSession { + id: string; // Unique session ID + agentId: string; // Agent ID (e.g., "claude") + process: ChildProcess; // Child process handle + alive: boolean; // Whether the process is alive + stdoutBuffer: string; // Accumulated stdout buffer + stderrBuffer: string; // Accumulated stderr buffer + createdAt: Date; // Created timestamp +} +``` + +### Events + +The `AcpManager` extends `EventEmitter` and emits the following events: + +#### `stdout` + +Emitted when the CLI agent writes to stdout. + +```typescript +acpManager.on("stdout", ({ sessionId, data }) => { + console.log(`[${sessionId}] stdout: ${data}`); +}); +``` + +#### `stderr` + +Emitted when the CLI agent writes to stderr. + +```typescript +acpManager.on("stderr", ({ sessionId, data }) => { + console.error(`[${sessionId}] stderr: ${data}`); +}); +``` + +#### `exit` + +Emitted when the CLI agent process exits. + +```typescript +acpManager.on("exit", ({ sessionId, code, signal }) => { + console.log(`[${sessionId}] exited with code ${code}, signal ${signal}`); +}); +``` + +#### `error` + +Emitted when the CLI agent process errors. + +```typescript +acpManager.on("error", ({ sessionId, error }) => { + console.error(`[${sessionId}] error: ${error}`); +}); +``` + +--- + +## Configuration + +### Environment Variables + +ACP inherits all environment variables from the parent process and can be extended with custom env vars: + +```typescript +acpManager.spawn("claude", "claude", [], { + ANTHROPIC_API_KEY: "sk-...", + DEBUG: "true", +}); +``` + +### Spawn Arguments + +Each agent has default spawn arguments defined in the registry. You can override them: + +```typescript +acpManager.spawn("claude", "claude", ["--print", "--verbose"], {}); +``` + +### Timeouts + +Default prompt timeout is **120 seconds** (2 minutes). You can override: + +```typescript +await acpManager.sendPrompt(sessionId, prompt, 300000); // 5 minutes +``` + +### Detection Cache + +Agent detection is cached for **60 seconds** to avoid expensive filesystem scans. Force refresh: + +```typescript +import { refreshAgentCache } from "@/lib/acp"; + +refreshAgentCache(); +``` + +--- + +## Security + +### Command Injection Prevention + +ACP validates version commands to prevent command injection attacks: + +```typescript +const DISALLOWED_VERSION_COMMAND_CHARS = /[;&|<>`$\r\n]/; +``` + +Version commands containing these characters are rejected: + +- `;` — Command separator +- `&` — Background process +- `|` — Pipe +- `<`, `>` — Redirection +- `` ` `` — Command substitution +- `$` — Variable expansion +- `\r`, `\n` — Line breaks + +### Binary Name Validation + +ACP validates that the version command binary matches the expected binary name (unless it's a custom agent). + +### Process Isolation + +Each ACP session runs in its own child process. The process is killed when the session ends or times out. + +--- + +## Performance + +### Detection Performance + +- **First call**: ~50-200ms (runs `version` command for each agent) +- **Cached calls**: <1ms (returns from cache) +- **Cache TTL**: 60 seconds + +### Prompt Performance + +- **Spawn**: ~50-100ms +- **Send prompt**: ~10-50ms +- **Wait for response**: Depends on CLI agent (typically 1-30 seconds) +- **Kill**: ~5 seconds (SIGTERM) + immediate (SIGKILL) + +### Resource Usage + +- **Memory per session**: ~10-50MB (depends on CLI agent) +- **CPU**: Minimal (I/O bound) +- **Disk**: None + +--- + +## Troubleshooting + +### "Unknown agent" Error + +**Problem**: `acpManager.spawn()` throws `Unknown agent: ` + +**Solution**: Only 4 agents are allowed in `spawn()`: +- `claude` +- `codex` +- `gemini` +- `qwen` + +Other agents must be spawned manually or via custom agent definitions. + +### "Session not alive" Error + +**Problem**: `acpManager.sendPrompt()` throws `Session ${sessionId} is not alive` + +**Solution**: The session may have exited or been killed. Check session status: + +```typescript +const session = acpManager.getSession(sessionId); +if (!session?.alive) { + // Re-spawn the session + acpManager.spawn("claude", "claude", [], {}); +} +``` + +### "ACP timeout" Error + +**Problem**: `acpManager.sendPrompt()` throws `ACP timeout after 120000ms` + +**Solution**: Increase the timeout: + +```typescript +await acpManager.sendPrompt(sessionId, prompt, 300000); // 5 minutes +``` + +### CLI Not Detected + +**Problem**: `detectInstalledAgents()` doesn't find your CLI + +**Solutions**: +1. **Check PATH**: Ensure the CLI is in your system PATH +2. **Check version command**: Run `claude --version` manually +3. **Check permissions**: Ensure the CLI is executable +4. **Custom agent**: Add a custom agent definition for non-standard CLIs + +### Permission Denied + +**Problem**: ACP can't execute the CLI + +**Solutions**: +1. **Check file permissions**: `chmod +x /usr/local/bin/claude` +2. **Check ownership**: Ensure OmniRoute has read/execute permissions +3. **Check SELinux/AppArmor**: May block process spawning + +--- + +## Examples + +### Example 1: Spawn and Use Claude Code + +```typescript +import { acpManager, detectInstalledAgents } from "@/lib/acp"; + +// Detect installed agents +const agents = detectInstalledAgents(); +const claude = agents.find((a) => a.id === "claude"); + +if (claude?.installed) { + // Spawn a new session + const session = acpManager.spawn( + "claude", + claude.binary, + ["--print", "--output-format", "json"] + ); + + // Send a prompt + const response = await acpManager.sendPrompt( + session.id, + "Explain quantum computing in 100 words" + ); + + console.log("Claude's response:", response); + + // Clean up + acpManager.kill(session.id); +} +``` + +### Example 2: Auto-Discovery with Fallback + +```typescript +import { acpManager, getAvailableAgents } from "@/lib/acp"; + +const available = getAvailableAgents(); + +// Try Claude first, fallback to Codex +let agentId = "claude"; +if (!available.find((a) => a.id === "claude")) { + if (available.find((a) => a.id === "codex")) { + agentId = "codex"; + } else { + throw new Error("No ACP-compatible CLI agent found"); + } +} + +const agent = available.find((a) => a.id === agentId)!; +const session = acpManager.spawn(agentId, agent.binary, agent.spawnArgs); + +const response = await acpManager.sendPrompt(session.id, "Hello!"); + +acpManager.kill(session.id); +``` + +### Example 3: Custom Agent + +```typescript +import { setCustomAgents, detectInstalledAgents } from "@/lib/acp"; + +// Register a custom CLI agent +setCustomAgents([ + { + id: "my-llm-cli", + name: "My LLM CLI", + binary: "myllm", + versionCommand: "myllm --version", + providerAlias: "my-llm-provider", + spawnArgs: ["--format", "json"], + protocol: "stdio", + }, +]); + +// Now detectInstalledAgents() will include "my-llm-cli" +const agents = detectInstalledAgents(); +``` + +--- + +## What's Next? + +- **[API Reference](../reference/API_REFERENCE.md)** — REST API endpoints +- **[Provider Reference](../reference/PROVIDER_REFERENCE.md)** — All 223 providers +- **[MCP Server](./MCP-SERVER.md)** — Model Context Protocol integration +- **[A2A Server](./A2A-SERVER.md)** — Agent-to-Agent protocol +- **[Cloud Agent](./CLOUD_AGENT.md)** — Cloud-based agents + +--- + +## Reference + +- [AionUi Project](https://github.com/iOfficeAI/AionUi) — Inspiration for ACP auto-detection +- [ACP Source Code](../../src/lib/acp/) — Implementation details + - `manager.ts` — Process lifecycle management + - `registry.ts` — Agent discovery and registration + - `index.ts` — Public API exports diff --git a/docs/frameworks/meta.json b/docs/frameworks/meta.json index faad60d4b0c6..46b65656e384 100644 --- a/docs/frameworks/meta.json +++ b/docs/frameworks/meta.json @@ -4,6 +4,7 @@ "MCP-SERVER", "A2A-SERVER", "AGENT_PROTOCOLS_GUIDE", + "ACP", "CLOUD_AGENT", "EVALS", "GAMIFICATION", diff --git a/docs/getting-started/AUTO-COMBO-GUIDE.md b/docs/getting-started/AUTO-COMBO-GUIDE.md new file mode 100644 index 000000000000..bc3bc66cd823 --- /dev/null +++ b/docs/getting-started/AUTO-COMBO-GUIDE.md @@ -0,0 +1,210 @@ +# Auto-Combo: Let OmniRoute Pick the Best AI for You + +> **TL;DR**: Set your model to `auto` and OmniRoute automatically picks the best AI provider for each request. No configuration needed. + +--- + +## What It Does + +Instead of choosing a specific AI model (like GPT-4o or Claude), you can let OmniRoute **automatically pick the best one** for each request. It considers: + +- **Health** — Is the provider working right now? +- **Speed** — How fast is it? +- **Cost** — How much does it cost? +- **Quality** — Is it good at this type of task? +- **Capacity** — Does it have quota remaining? + +OmniRoute scores all your connected providers and picks the best one. If it fails, it automatically tries the next one. + +--- + +## Quick Start + +**Step 1**: Set your model to `auto` in your IDE or CLI: + +``` +model: "auto" +``` + +**Step 2**: That's it! OmniRoute handles the rest. + +**Step 3** (optional): Use a variant for specific tasks: + +``` +model: "auto/coding" # Best for code +model: "auto/fast" # Fastest response +model: "auto/cheap" # Cheapest option +``` + +--- + +## Which "auto" Should I Use? + +| If you want... | Use this | Best for | How it works | +|----------------|----------|----------|--------------| +| **Best overall** | `auto` | General questions, chat | Balances speed, cost, and quality | +| **Best code** | `auto/coding` | Writing code, debugging | Picks models good at coding tasks | +| **Fastest response** | `auto/fast` | Quick answers, low latency | Prioritizes speed over everything | +| **Cheapest option** | `auto/cheap` | Saving money | Picks the cheapest provider | +| **Smartest model** | `auto/smart` | Complex tasks | Quality-first + explores new models | +| **Most available** | `auto/offline` | When providers are busy | Picks providers with most capacity | + +### Examples + +```bash +# General chat — balanced +curl http://localhost:20128/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model":"auto","messages":[{"role":"user","content":"Hello!"}]}' + +# Code generation — quality-first +curl http://localhost:20128/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model":"auto/coding","messages":[{"role":"user","content":"Write a Python function"}]}' + +# Quick answer — speed-first +curl http://localhost:20128/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model":"auto/fast","messages":[{"role":"user","content":"What is 2+2?"}]}' +``` + +--- + +## How It Works (Simple Version) + +When you send a request with `model: "auto"`, OmniRoute: + +1. **Looks at all your connected providers** — Every provider you've added (OpenAI, Anthropic, Google, etc.) +2. **Scores each one** on 5 factors: + - Is it working? (health) + - Does it have capacity? (quota) + - How much does it cost? (price) + - How fast is it? (speed) + - Is it good at this task? (quality) +3. **Picks the best one** — The highest-scoring provider gets your request +4. **Auto-recovers** — If it fails, OmniRoute tries the next one automatically + +### The Scoring System + +Each provider gets a score from 0 to 1. The higher the score, the better the fit. + +| Factor | Weight | What it means | +|--------|--------|---------------| +| Health | 20% | Is the provider working? (circuit breaker state) | +| Quota | 15% | Does it have capacity remaining? | +| Cost | 15% | How expensive is it? (cheaper = higher score) | +| Speed | 12% | How fast is it? (lower latency = higher score) | +| Task Fit | 8% | Is it good at this type of task? | +| Stability | 5% | Is it consistent? (low error rate) | +| Tier | 5% | Account tier (Ultra > Pro > Free) | +| Other | 20% | Context affinity, connection density, etc. | + +### How Variants Change the Scoring + +Each variant uses different weights: + +| Variant | Prioritizes | Key Weights | +|---------|-------------|-------------| +| `auto` | Balanced | health=20%, quota=15%, cost=15% | +| `auto/coding` | Quality | taskFit=37%, stability=15% | +| `auto/fast` | Speed | latency=32%, health=28% | +| `auto/cheap` | Cost | cost=37% | +| `auto/smart` | Quality + Explore | taskFit=37%, exploration=10% | +| `auto/offline` | Capacity | quota=37%, health=28% | + +--- + +## How It Handles Failures + +OmniRoute has **three layers of protection**: + +### 1. Auto-Fallback +If the best provider fails, OmniRoute automatically tries the next one. You don't need to do anything. + +### 2. Self-Healing +If a provider keeps failing: +- **Score < 0.2** → Excluded for 5 minutes +- **Circuit breaker open** → Auto-excluded +- **More than 50% providers down** → Incident mode (no exploration) + +### 3. Emergency Fallback +If all providers fail, OmniRoute routes to stable free providers (like Kiro or Qoder) as a last resort. + +--- + +## Multi-Account Support + +If you have multiple accounts for the same provider (e.g., two OpenAI keys), OmniRoute treats each as a **separate candidate**. This means: + +- Account A has quota remaining → use it +- Account B is rate-limited → skip it +- Account C is cheaper → prefer it + +Each account is scored independently based on its own health, quota, and speed. + +--- + +## Bandit Exploration + +OmniRoute occasionally **explores** new providers to discover better options: + +- **Default**: 5% of requests go to random providers +- **Auto/smart**: 10% exploration rate +- **Disabled** when more than 50% of providers are unhealthy + +This helps OmniRoute learn which providers work best for your usage patterns. + +--- + +## Common Questions + +### "Will it always pick the most expensive model?" + +**No.** Cost is only 15% of the score by default. A cheap, fast, healthy provider can beat an expensive one. Use `auto/cheap` if you want to prioritize cost even more. + +### "What if a provider goes down?" + +OmniRoute automatically skips it and tries the next one. If a provider keeps failing, it's excluded temporarily (5-30 minutes). You don't need to do anything. + +### "Can I see which provider was used?" + +Check the response headers — OmniRoute includes the provider and model used in each response. + +### "Does it learn from my usage?" + +Yes! The scoring system uses historical data (latency, error rates, success rates) to make better decisions over time. + +### "What's the difference between `auto` and `auto/smart`?" + +- `auto` — Balanced, 5% exploration +- `auto/smart` — Quality-first (same weights as `auto/coding`), 10% exploration + +Use `auto/smart` when you want the best quality and are okay with occasional exploration. + +### "Can I force a specific provider?" + +Yes! Use a combo with `priority` strategy instead of `auto`. See the [Technical Reference](../routing/AUTO-COMBO.md) for details. + +### "How is this different from round-robin?" + +Round-robin cycles through providers in order. Auto-combo **scores each provider** and picks the best one. It's smarter — it considers health, speed, cost, and quality. + +--- + +## What's Next? + +- **[Connect a Provider](./PROVIDERS-GUIDE.md)** — Add your first AI provider +- **[Free Tiers Guide](./FREE-TIERS-GUIDE.md)** — Get free AI with no credit card +- **[Troubleshooting](./TROUBLESHOOTING.md)** — Fix common issues +- **[Technical Reference](../routing/AUTO-COMBO.md)** — Deep dive into the scoring algorithm + +--- + +## Learn More + +For developers and contributors, see the [Auto-Combo Technical Reference](../routing/AUTO-COMBO.md) for: +- Full 12-factor scoring algorithm +- Mode pack weight tables +- Implementation file paths +- API endpoints +- Self-healing algorithm details diff --git a/docs/getting-started/FREE-TIERS-GUIDE.md b/docs/getting-started/FREE-TIERS-GUIDE.md new file mode 100644 index 000000000000..55eb41d0c8ef --- /dev/null +++ b/docs/getting-started/FREE-TIERS-GUIDE.md @@ -0,0 +1,270 @@ +# Free Tiers Guide: Get Free AI Without a Credit Card + +> **TL;DR**: OmniRoute aggregates free tiers from 50+ providers. Connect multiple free providers for unlimited free AI with automatic fallback. + +--- + +## What Are Free Tiers? + +Many AI providers offer **free usage** — no credit card required. Think of it like free samples at a grocery store. You can try the product without paying. + +OmniRoute **aggregates** these free tiers into one endpoint. Instead of signing up for 10 different services, you connect them all to OmniRoute and use `model: "auto"` to automatically pick the best free option for each request. + +--- + +## Best Free Providers (No Credit Card) + +### Tier 1: Free Forever (Unlimited) + +These providers are **always free** with no limits: + +| Provider | Models | Quota | How to Connect | +|----------|--------|-------|----------------| +| **Kiro AI** | Claude Sonnet 4.5, Haiku 4.5, Opus 4.6 | 50 credits/month | No auth needed | +| **OpenCode Free** | GPT-4o, Claude, Gemini | Unlimited | No auth needed | +| **Pollinations** | GPT-5, Claude, Gemini, DeepSeek, Llama 4 | No key needed | No auth needed | +| **LongCat** | LongCat-Flash-Lite | 50M tokens/day | No auth needed | +| **Cloudflare AI** | 50+ models | 10K neurons/day | No auth needed | +| **Qwen** | Qwen3-coder-plus/flash/next | Unlimited | No auth needed | +| **Qoder** | Kimi-K2, DeepSeek-R1, Qwen3-coder | Unlimited | No auth needed | + +### Tier 2: Free with Signup (Generous) + +These providers give you **free credits** when you sign up: + +| Provider | Free Credits | Models | How to Get | +|----------|-------------|--------|------------| +| **NVIDIA NIM** | ~40 RPM | 129 models | Sign up at build.nvidia.com | +| **Cerebras** | 1M tokens/day | Qwen3 235B, GPT-OSS 120B | Sign up at cerebras.ai | +| **DeepSeek** | 5M free tokens | DeepSeek V4 | Sign up at platform.deepseek.com | +| **Groq** | 30 RPM free | Llama 4, Mixtral | Sign up at console.groq.com | +| **OpenAI** | $5 free credits | GPT-5, GPT-4o | Sign up at platform.openai.com | +| **Anthropic** | $5 free credits | Claude Opus 4.6, Sonnet 4.6 | Sign up at console.anthropic.com | +| **Google** | 1,500 req/day | Gemini 2.5 Pro, Flash | Sign up at aistudio.google.com | + +### Tier 3: Free with Limits (Specific Use Cases) + +These providers have **free tiers** with specific limits: + +| Provider | Free Limit | Models | Best For | +|----------|-----------|--------|----------| +| **Cerebras** | 1M tokens/day | Qwen3 235B | Fast inference | +| **NVIDIA NIM** | ~40 RPM | 129 models | Variety | +| **Groq** | 30 RPM | Llama 4, Mixtral | Speed | +| **Cloudflare AI** | 10K neurons/day | 50+ models | Variety | + +--- + +## How to Stack Free Tiers + +The magic of OmniRoute is **stacking free tiers**. Instead of relying on one provider, you connect multiple free providers and let OmniRoute automatically pick the best one for each request. + +### Example: Unlimited Free AI + +Connect these 4 providers for **unlimited free AI**: + +1. **Kiro AI** — 50 credits/month (Claude models) +2. **OpenCode Free** — Unlimited (GPT models) +3. **Pollinations** — No key needed (multiple models) +4. **LongCat** — 50M tokens/day (backup) + +Then use `model: "auto"` and OmniRoute will: +- Try Kiro first (best quality) +- If Kiro is busy → try OpenCode Free +- If OpenCode Free is slow → try Pollinations +- If all fail → use LongCat as backup + +**Result**: Unlimited free AI with automatic fallback! + +--- + +## How to Connect Free Providers + +### Step 1: Open the Dashboard + +Go to `http://localhost:20128` in your browser. + +### Step 2: Go to Providers + +Click **Providers** in the sidebar. + +### Step 3: Click Add Provider + +Click the **+ Add Provider** button. + +### Step 4: Select a Free Provider + +Browse the list and select one of these free providers: +- **Kiro AI** — Free Claude models +- **OpenCode Free** — Free GPT models +- **Pollinations** — Free GPT-5, Claude, Gemini +- **LongCat** — 50M tokens/day free +- **Cloudflare AI** — 50+ models, 10K neurons/day + +### Step 5: Click Connect + +No API key needed — just click **Connect**. + +### Step 6: Repeat + +Connect 3-4 free providers for the best experience. + +--- + +## Free Provider Details + +### Kiro AI + +- **Models**: Claude Sonnet 4.5, Haiku 4.5, Opus 4.6 +- **Quota**: 50 credits/month +- **Auth**: No auth needed +- **Best for**: High-quality Claude models + +### OpenCode Free + +- **Models**: GPT-4o, Claude, Gemini +- **Quota**: Unlimited +- **Auth**: No auth needed +- **Best for**: General-purpose AI + +### Pollinations + +- **Models**: GPT-5, Claude, Gemini, DeepSeek, Llama 4 +- **Quota**: No key needed +- **Auth**: No auth needed +- **Best for**: Variety of models + +### LongCat + +- **Models**: LongCat-Flash-Lite +- **Quota**: 50M tokens/day +- **Auth**: No auth needed +- **Best for**: High-volume usage + +### Cloudflare AI + +- **Models**: 50+ models +- **Quota**: 10K neurons/day +- **Auth**: No auth needed +- **Best for**: Variety and reliability + +### NVIDIA NIM + +- **Models**: 129 models +- **Quota**: ~40 RPM +- **Auth**: Sign up at build.nvidia.com +- **Best for**: Variety and speed + +### Cerebras + +- **Models**: Qwen3 235B, GPT-OSS 120B +- **Quota**: 1M tokens/day +- **Auth**: Sign up at cerebras.ai +- **Best for**: Fast inference + +### Qwen + +- **Models**: Qwen3-coder-plus/flash/next +- **Quota**: Unlimited +- **Auth**: No auth needed +- **Best for**: Coding tasks + +### Qoder + +- **Models**: Kimi-K2, DeepSeek-R1, Qwen3-coder +- **Quota**: Unlimited +- **Auth**: No auth needed +- **Best for**: Coding tasks + +--- + +## How OmniRoute Makes Free Tiers Better + +### 1. Automatic Fallback + +If one free provider is busy or down, OmniRoute automatically tries the next one. You don't need to do anything. + +### 2. Smart Routing + +OmniRoute picks the **best free provider** for each request based on: +- Speed — Which provider is fastest right now? +- Quality — Which provider is best for this task? +- Capacity — Which provider has quota remaining? + +### 3. Token Savings + +OmniRoute's **compression** feature saves 15-95% of tokens. This means your free quota lasts **5-20x longer**. + +### 4. Multi-Account Support + +If you have multiple accounts for the same provider, OmniRoute treats each as a separate candidate. This doubles or triples your free quota. + +--- + +## Free Tier Math + +Let's calculate how much free AI you can get: + +### Conservative Estimate (3 providers) + +| Provider | Daily Quota | Monthly Quota | +|----------|-------------|---------------| +| Kiro AI | ~1.7 credits | 50 credits | +| OpenCode Free | Unlimited | Unlimited | +| Pollinations | Unlimited | Unlimited | + +**Total**: Unlimited free AI + +### Aggressive Estimate (7 providers) + +| Provider | Daily Quota | Monthly Quota | +|----------|-------------|---------------| +| Kiro AI | ~1.7 credits | 50 credits | +| OpenCode Free | Unlimited | Unlimited | +| Pollinations | Unlimited | Unlimited | +| LongCat | 50M tokens | 1.5B tokens | +| Cloudflare AI | 10K neurons | 300K neurons | +| NVIDIA NIM | ~40 RPM | ~1.7M requests | +| Cerebras | 1M tokens | 30M tokens | + +**Total**: ~1.9B+ free tokens/month (with compression: ~9.5B+ effective tokens) + +--- + +## Common Questions + +### "Is this really free?" + +**Yes!** These are official free tiers from the providers. OmniRoute just makes it easier to use them all at once. + +### "Will the free tier run out?" + +Some providers have limits (like Kiro's 50 credits/month), but others are unlimited (like OpenCode Free and Pollinations). By connecting multiple providers, you always have a backup. + +### "Can I use free providers for production?" + +**Yes!** Many free providers are production-ready. However, for critical applications, consider adding a paid provider as a backup. + +### "What's the catch?" + +No catch! Providers offer free tiers to attract users. OmniRoute just makes it easier to use them all at once. + +### "How do I get more free quota?" + +1. Connect more free providers +2. Use compression to save tokens (15-95% savings) +3. Use `auto/cheap` to prioritize free/cheap providers +4. Create multiple accounts for the same provider + +### "Do free providers have worse quality?" + +**Not necessarily!** Many free providers offer the same models as paid providers. For example, Kiro gives you access to Claude Sonnet 4.5 — the same model you'd get with a paid Anthropic subscription. + +--- + +## What's Next? + +- **[Auto-Combo Guide](./AUTO-COMBO-GUIDE.md)** — Let OmniRoute pick the best AI for you +- **[Providers Guide](./PROVIDERS-GUIDE.md)** — Connect more providers +- **[Troubleshooting](./TROUBLESHOOTING.md)** — Fix common issues +- **[Free Tiers Reference](../reference/FREE_TIERS.md)** — Full list of free tiers diff --git a/docs/getting-started/PROVIDERS-GUIDE.md b/docs/getting-started/PROVIDERS-GUIDE.md new file mode 100644 index 000000000000..1cda76bfcac8 --- /dev/null +++ b/docs/getting-started/PROVIDERS-GUIDE.md @@ -0,0 +1,220 @@ +# Providers Guide: Connect AI Models to OmniRoute + +> **TL;DR**: A provider is a connection to an AI service (like OpenAI, Anthropic, Google). You need at least one provider to use OmniRoute. + +--- + +## What Is a Provider? + +Think of a provider like a **phone carrier**. Just as you need a phone carrier to make calls, you need an AI provider to use AI models. OmniRoute is like a phone that works with **all carriers** — you can switch between them automatically. + +### Types of Providers + +| Type | What It Is | Examples | Cost | +|------|-----------|----------|------| +| **Free** | No payment required | Kiro, OpenCode Free, Pollinations | $0 | +| **API Key** | You need an API key | OpenAI, Anthropic, Google | Pay per use | +| **OAuth** | Login with your account | Claude Code, GitHub Copilot | Subscription | +| **Web Cookie** | Uses your browser session | ChatGPT Web, Gemini Web | $0 (uses your account) | + +--- + +## Quick Start: Connect Your First Provider + +### Option A: Free Provider (No Credit Card) + +1. Open the dashboard at `http://localhost:20128` +2. Go to **Providers** → **Add Provider** +3. Select one of these free providers: + - **Kiro AI** — Free Claude models (no auth needed) + - **OpenCode Free** — Free GPT models (no auth needed) + - **Pollinations** — Free GPT-5, Claude, Gemini (no key needed) + - **LongCat** — 50M tokens/day free + - **Cloudflare AI** — 50+ models, 10K neurons/day +4. Click **Connect** +5. Done! You now have free AI access. + +### Option B: API Key Provider (Paid) + +1. Get an API key from the provider's website: + - **OpenAI**: https://platform.openai.com/api-keys + - **Anthropic**: https://console.anthropic.com/ + - **Google**: https://aistudio.google.com/apikey + - **DeepSeek**: https://platform.deepseek.com/ + - **Groq**: https://console.groq.com/ +2. Open the dashboard at `http://localhost:20128` +3. Go to **Providers** → **Add Provider** +4. Select your provider +5. Paste your API key +6. Click **Connect** +7. Done! You now have access to that provider's models. + +### Option C: OAuth Provider (Subscription) + +1. Open the dashboard at `http://localhost:20128` +2. Go to **Providers** → **Add Provider** +3. Select your provider (e.g., Claude Code, GitHub Copilot) +4. Click **Connect with OAuth** +5. Login with your account +6. Done! You now have access to your subscription models. + +--- + +## Best Free Providers + +These providers offer **free access** with no credit card: + +| Provider | Free Quota | Models | How to Connect | +|----------|-----------|--------|----------------| +| **Kiro AI** | 50 credits/month | Claude Sonnet 4.5, Haiku 4.5, Opus 4.6 | No auth needed | +| **OpenCode Free** | Unlimited | GPT-4o, Claude, Gemini | No auth needed | +| **Pollinations** | No key needed | GPT-5, Claude, Gemini, DeepSeek, Llama 4 | No auth needed | +| **LongCat** | 50M tokens/day | LongCat-Flash-Lite | No auth needed | +| **Cloudflare AI** | 10K neurons/day | 50+ models | No auth needed | +| **NVIDIA NIM** | ~40 RPM | 129 models | API key needed | +| **Cerebras** | 1M tokens/day | Qwen3 235B, GPT-OSS 120B | API key needed | +| **Qwen** | Unlimited | Qwen3-coder-plus/flash/next | No auth needed | +| **Qoder** | Unlimited | Kimi-K2, DeepSeek-R1, Qwen3-coder | No auth needed | + +**Tip**: Connect multiple free providers for **unlimited free AI** with automatic fallback! + +--- + +## Best Paid Providers + +These providers offer **high-quality models** with API keys: + +| Provider | Best Models | Cost | Free Tier | +|----------|------------|------|-----------| +| **OpenAI** | GPT-5, GPT-4o | $2.50-$10/1M tokens | $5 free credits | +| **Anthropic** | Claude Opus 4.6, Sonnet 4.6 | $3-$15/1M tokens | $5 free credits | +| **Google** | Gemini 2.5 Pro, Flash | $0.075-$1.25/1M tokens | 1,500 req/day free | +| **DeepSeek** | DeepSeek V4 | $0.14-$0.28/1M tokens | 5M free tokens | +| **Groq** | Llama 4, Mixtral | $0.05-$0.27/1M tokens | 30 RPM free | +| **xAI** | Grok 3 | $0.30-$0.60/1M tokens | — | + +--- + +## How to Connect a Provider (Step-by-Step) + +### Step 1: Open the Dashboard + +Go to `http://localhost:20128` in your browser. + +### Step 2: Go to Providers + +Click **Providers** in the sidebar. + +### Step 3: Click Add Provider + +Click the **+ Add Provider** button. + +### Step 4: Select Your Provider + +Browse the list or search for your provider. Click on it. + +### Step 5: Enter Credentials + +- **Free providers**: No credentials needed — just click **Connect** +- **API key providers**: Paste your API key +- **OAuth providers**: Click **Connect with OAuth** and login + +### Step 6: Test the Connection + +Click **Test Connection** to verify it works. + +### Step 7: Done! + +Your provider is now connected. You can use it with `model: "auto"` or specify the provider directly. + +--- + +## Using Multiple Providers + +OmniRoute works best with **multiple providers**. This gives you: + +- **Automatic fallback** — If one provider fails, OmniRoute tries the next +- **Cost optimization** — OmniRoute picks the cheapest provider for each request +- **Speed optimization** — OmniRoute picks the fastest provider for each request +- **Quality optimization** — OmniRoute picks the best provider for each task + +### Recommended Setup + +Connect at least **3 providers** for the best experience: + +1. **One free provider** (Kiro, OpenCode Free, or Pollinations) — Always available +2. **One fast provider** (Groq, Cerebras) — For quick responses +3. **One quality provider** (OpenAI, Anthropic, Google) — For complex tasks + +Then use `model: "auto"` and OmniRoute will automatically pick the best one for each request. + +--- + +## Provider-Specific Setup + +### OpenAI + +1. Get API key: https://platform.openai.com/api-keys +2. In OmniRoute: Providers → Add Provider → OpenAI +3. Paste API key → Connect + +### Anthropic + +1. Get API key: https://console.anthropic.com/ +2. In OmniRoute: Providers → Add Provider → Anthropic +3. Paste API key → Connect + +### Google (Gemini) + +1. Get API key: https://aistudio.google.com/apikey +2. In OmniRoute: Providers → Add Provider → Gemini +3. Paste API key → Connect + +### DeepSeek + +1. Get API key: https://platform.deepseek.com/ +2. In OmniRoute: Providers → Add Provider → DeepSeek +3. Paste API key → Connect + +### Groq + +1. Get API key: https://console.groq.com/ +2. In OmniRoute: Providers → Add Provider → Groq +3. Paste API key → Connect + +--- + +## Common Questions + +### "Do I need to pay to use OmniRoute?" + +**No!** OmniRoute is free and open-source. You can use free providers (Kiro, OpenCode Free, Pollinations) without paying anything. You only pay if you choose to use paid providers. + +### "Which provider should I start with?" + +Start with **Kiro AI** — it's free, requires no API key, and gives you access to Claude models. Then add more providers as needed. + +### "Can I use multiple providers at once?" + +**Yes!** That's the whole point of OmniRoute. Connect multiple providers and use `model: "auto"` to let OmniRoute pick the best one for each request. + +### "What if a provider goes down?" + +OmniRoute automatically skips failed providers and tries the next one. You don't need to do anything. + +### "How do I disconnect a provider?" + +Go to Providers → click on the provider → click **Disconnect**. + +### "Can I use my existing API keys?" + +**Yes!** If you already have API keys for OpenAI, Anthropic, Google, etc., you can use them in OmniRoute. Just paste them when connecting the provider. + +--- + +## What's Next? + +- **[Auto-Combo Guide](./AUTO-COMBO-GUIDE.md)** — Let OmniRoute pick the best AI for you +- **[Free Tiers Guide](./FREE-TIERS-GUIDE.md)** — Get free AI with no credit card +- **[Troubleshooting](./TROUBLESHOOTING.md)** — Fix common issues +- **[Provider Reference](../reference/PROVIDER_REFERENCE.md)** — Full list of 177 providers diff --git a/docs/getting-started/QUICK-START.md b/docs/getting-started/QUICK-START.md new file mode 100644 index 000000000000..e0ce54499ea2 --- /dev/null +++ b/docs/getting-started/QUICK-START.md @@ -0,0 +1,135 @@ +# Quick Start: Get OmniRoute Running in 3 Minutes + +> **TL;DR**: Install → Connect a free provider → Point your IDE to OmniRoute. Done. + +--- + +## Step 1: Install OmniRoute + +Choose your preferred method: + +### Option A: npm (Recommended) + +```bash +npm install -g omniroute +``` + +### Option B: Docker + +```bash +docker run -d --name omniroute -p 20128:20128 diegosouzapw/omniroute:latest +``` + +### Option C: From Source + +```bash +git clone https://github.com/diegosouzapw/OmniRoute.git +cd OmniRoute +npm install +npm run dev +``` + +--- + +## Step 2: Start OmniRoute + +```bash +omniroute +``` + +OmniRoute starts at `http://localhost:20128`. The dashboard opens automatically. + +--- + +## Step 3: Connect a Free Provider + +You can use OmniRoute **without paying anything** by connecting a free provider. + +### Option A: Kiro (Free Claude — No Credit Card) + +1. Open the dashboard at `http://localhost:20128` +2. Go to **Providers** → **Add Provider** +3. Select **Kiro AI** +4. Click **Connect** (no API key needed!) +5. Done! You now have free access to Claude models. + +### Option B: OpenCode Free (No Auth) + +1. Open the dashboard at `http://localhost:20128` +2. Go to **Providers** → **Add Provider** +3. Select **OpenCode Free** +4. Click **Connect** (no API key needed!) +5. Done! You now have free access to multiple models. + +### Option C: Pollinations (No Key Needed) + +1. Open the dashboard at `http://localhost:20128` +2. Go to **Providers** → **Add Provider** +3. Select **Pollinations** +4. Click **Connect** (no API key needed!) +5. Done! You now have free access to GPT-5, Claude, Gemini, and more. + +--- + +## Step 4: Point Your IDE to OmniRoute + +In your IDE or CLI tool, set: + +``` +Base URL: http://localhost:20128/v1 +API Key: [copy from Dashboard → Endpoints] +Model: auto +``` + +That's it! Your IDE now uses OmniRoute with automatic provider selection. + +--- + +## Step 5: Verify It Works + +```bash +curl http://localhost:20128/v1/models -H "Authorization: Bearer YOUR_KEY" +``` + +You should see your connected models listed. + +--- + +## What's Next? + +- **[Auto-Combo Guide](./AUTO-COMBO-GUIDE.md)** — Let OmniRoute pick the best AI for you +- **[Providers Guide](./PROVIDERS-GUIDE.md)** — Connect more providers (free and paid) +- **[Free Tiers Guide](./FREE-TIERS-GUIDE.md)** — Get free AI with no credit card +- **[Troubleshooting](./TROUBLESHOOTING.md)** — Fix common issues + +--- + +## Common Questions + +### "Do I need an API key?" + +**No!** You can use free providers (Kiro, OpenCode Free, Pollinations) without any API key. Just connect them in the dashboard. + +### "What is `auto`?" + +`auto` tells OmniRoute to automatically pick the best provider for each request. It considers speed, cost, quality, and availability. See the [Auto-Combo Guide](./AUTO-COMBO-GUIDE.md) for details. + +### "How much does it cost?" + +OmniRoute itself is **free and open-source**. You only pay for the providers you use. Many providers have free tiers — see the [Free Tiers Guide](./FREE-TIERS-GUIDE.md). + +### "Can I use it with Claude Code / Cursor / Copilot?" + +**Yes!** OmniRoute works with any tool that supports OpenAI format. Just set the base URL to `http://localhost:20128/v1`. See the [CLI Tools Guide](../reference/CLI-TOOLS.md) for specific setup instructions. + +### "What if a provider goes down?" + +OmniRoute automatically skips failed providers and tries the next one. You don't need to do anything. See the [Auto-Combo Guide](./AUTO-COMBO-GUIDE.md) for details. + +--- + +## Need Help? + +- **[Troubleshooting](./TROUBLESHOOTING.md)** — Common issues and fixes +- **[Discord](https://discord.gg/hmexnhgE)** — Community support +- **[GitHub Issues](https://github.com/diegosouzapw/OmniRoute/issues)** — Report bugs diff --git a/docs/getting-started/TROUBLESHOOTING.md b/docs/getting-started/TROUBLESHOOTING.md new file mode 100644 index 000000000000..04fcb25f746f --- /dev/null +++ b/docs/getting-started/TROUBLESHOOTING.md @@ -0,0 +1,501 @@ +--- +title: "Troubleshooting" +version: 3.8.2 +lastUpdated: 2026-05-13 +--- + +# Troubleshooting + +> **For Users**: Looking for quick fixes? See the [Quick Reference](#quick-reference) below. + +🌐 **Languages:** 🇺🇸 [English](./TROUBLESHOOTING.md) | 🇧🇷 [Português (Brasil)](../i18n/pt-BR/docs/guides/TROUBLESHOOTING.md) | 🇪🇸 [Español](../i18n/es/docs/guides/TROUBLESHOOTING.md) | 🇫🇷 [Français](../i18n/fr/docs/guides/TROUBLESHOOTING.md) | 🇮🇹 [Italiano](../i18n/it/docs/guides/TROUBLESHOOTING.md) | 🇷🇺 [Русский](../i18n/ru/docs/guides/TROUBLESHOOTING.md) | 🇨🇳 [中文 (简体)](../i18n/zh-CN/docs/guides/TROUBLESHOOTING.md) | 🇩🇪 [Deutsch](../i18n/de/docs/guides/TROUBLESHOOTING.md) | 🇮🇳 [हिन्दी](../i18n/in/docs/guides/TROUBLESHOOTING.md) | 🇹🇭 [ไทย](../i18n/th/docs/guides/TROUBLESHOOTING.md) | 🇺🇦 [Українська](../i18n/uk-UA/docs/guides/TROUBLESHOOTING.md) | 🇸🇦 [العربية](../i18n/ar/docs/guides/TROUBLESHOOTING.md) | 🇯🇵 [日本語](../i18n/ja/docs/guides/TROUBLESHOOTING.md) | 🇻🇳 [Tiếng Việt](../i18n/vi/docs/guides/TROUBLESHOOTING.md) | 🇧🇬 [Български](../i18n/bg/docs/guides/TROUBLESHOOTING.md) | 🇩🇰 [Dansk](../i18n/da/docs/guides/TROUBLESHOOTING.md) | 🇫🇮 [Suomi](../i18n/fi/docs/guides/TROUBLESHOOTING.md) | 🇮🇱 [עברית](../i18n/he/docs/guides/TROUBLESHOOTING.md) | 🇭🇺 [Magyar](../i18n/hu/docs/guides/TROUBLESHOOTING.md) | 🇮🇩 [Bahasa Indonesia](../i18n/id/docs/guides/TROUBLESHOOTING.md) | 🇰🇷 [한국어](../i18n/ko/docs/guides/TROUBLESHOOTING.md) | 🇲🇾 [Bahasa Melayu](../i18n/ms/docs/guides/TROUBLESHOOTING.md) | 🇳🇱 [Nederlands](../i18n/nl/docs/guides/TROUBLESHOOTING.md) | 🇳🇴 [Norsk](../i18n/no/docs/guides/TROUBLESHOOTING.md) | 🇵🇹 [Português (Portugal)](../i18n/pt/docs/guides/TROUBLESHOOTING.md) | 🇷🇴 [Română](../i18n/ro/docs/guides/TROUBLESHOOTING.md) | 🇵🇱 [Polski](../i18n/pl/docs/guides/TROUBLESHOOTING.md) | 🇸🇰 [Slovenčina](../i18n/sk/docs/guides/TROUBLESHOOTING.md) | 🇸🇪 [Svenska](../i18n/sv/docs/guides/TROUBLESHOOTING.md) | 🇵🇭 [Filipino](../i18n/phi/docs/guides/TROUBLESHOOTING.md) | 🇨🇿 [Čeština](../i18n/cs/docs/guides/TROUBLESHOOTING.md) + +Common problems and solutions for OmniRoute. + +--- + +## Quick Reference + +**New to OmniRoute?** Start here — these solve 90% of problems: + +| I see this | What it means | What to do | +|------------|--------------|------------| +| "Can't connect" | OmniRoute isn't running | Run `omniroute` or `docker restart omniroute` | +| "Invalid API key" | Your key is wrong or expired | Re-copy the key from the provider's website | +| "Rate limit exceeded" | You're sending too many requests | Wait 1 minute, or use `model: "auto"` for automatic fallback | +| "Quota exceeded" | You've used up your free/paid quota | Connect more providers, or use free providers (Kiro, Pollinations) | +| "Slow responses" | Provider is busy or far away | Use `model: "auto/fast"` or connect a faster provider (Groq, Cerebras) | +| "Wrong provider used" | `auto` picked a different provider | That's normal! `auto` picks the best one. Force a specific provider with `model: "openai/gpt-4o"` | +| "502 Bad Gateway" | Provider is down | Wait and retry, or use `model: "auto"` to switch providers | +| "401 Unauthorized" | Your credentials are wrong | Check your API key or re-authenticate with OAuth | +| "429 Too Many Requests" | Rate limited | Wait 1 minute, or connect more providers | + +**Still stuck?** See the [Quick Fixes](#quick-fixes) below, or ask on [Discord](https://discord.gg/hmexnhgE). + +--- + +## Quick Fixes + +| Problem | Solution | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| First login not working | Set `INITIAL_PASSWORD` in `.env` (no hardcoded default) | +| Dashboard opens on wrong port | Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` | +| No logs written to disk | Set `APP_LOG_TO_FILE=true` and verify call log capture is enabled | +| EACCES: permission denied | Set `DATA_DIR=/path/to/writable/dir` to override `~/.omniroute` | +| Routing strategy not saving | Update to the latest v3.x release (Zod schema fix for settings persistence shipped in earlier versions) | +| Login crash / blank page | Check Node.js version — see [Node.js Compatibility](#nodejs-compatibility) below | +| `dlopen` / `slice is not valid mach-o file` (macOS) | Run `cd $(npm root -g)/omniroute/app && npm rebuild better-sqlite3 && omniroute` — see [macOS native module rebuild](#macos-native-module-rebuild) below | +| Proxy "fetch failed" | Ensure proxy config is set at the correct level — see [Proxy Issues](#proxy-issues) below | + +--- + +## Node.js Compatibility + + + +### Login page crashes or shows "Module self-registration" error + +**Cause:** You are running a Node.js version outside OmniRoute's approved secure runtime floor. The most common case is running an older Node 20, 22, or 24 patch level that falls below the patched security floor OmniRoute requires. + +**Symptoms:** + +- Login page shows a blank screen or a server error +- Console shows `Error: Module did not self-register` or similar native binding errors +- The login page shows an **orange warning banner** with your Node version if the runtime is outside the supported secure policy + +**Fix:** + +1. Install a supported Node.js LTS release (recommended: Node.js 24.x): + ```bash + nvm install 24 + nvm use 24 + ``` +2. Verify your version: `node --version` should show `v24.0.0` or newer on the 24.x LTS line +3. Reinstall OmniRoute: `npm install -g omniroute` +4. Restart: `omniroute` + +> **Supported secure versions:** `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <27`. Node.js 24.x LTS (Krypton) and Node.js 26 are fully supported. + +### macOS: `dlopen` / "slice is not valid mach-o file" + + + +**Cause:** After a global `npm install -g omniroute`, the `better-sqlite3` native binary inside the package may have been compiled for a different architecture or Node.js ABI than what is running locally. This is common on macOS (both Apple Silicon and Intel) when the pre-built binary does not match your environment. + +**Symptoms:** + +- Server fails immediately on startup with a `dlopen` error +- Error contains `slice is not valid mach-o file` +- Full example: + +``` +dlopen(/Users//.nvm/versions/node/v24.14.1/lib/node_modules/omniroute/app/node_modules/better-sqlite3/build/Release/better_sqlite3.node, 0x0001): tried: '...' (slice is not valid mach-o file) +``` + +**Fix — rebuild for your local environment (no Node.js downgrade required):** + +```bash +cd $(npm root -g)/omniroute/app +npm rebuild better-sqlite3 +omniroute +``` + +> **Note:** This recompiles the native binding against your local Node.js version and CPU architecture, resolving the binary mismatch. The officially supported range is **`>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <27`** (`engines` field in `package.json`). Node.js 24.x LTS (Krypton) and Node.js 26 are fully supported with `better-sqlite3` v12.x. + +--- + +## Proxy Issues + + + +### Provider validation shows "fetch failed" + +**Cause:** The API key validation endpoint (`POST /api/providers/validate`) was previously bypassing proxy configuration, causing failures in environments that require proxy routing. + +**Fix (v3.5.5+):** This is now fixed. Provider validation routes through `runWithProxyContext`, honoring provider-level and global proxy settings automatically. + +### Token health check fails with "fetch failed" + +**Cause:** Background OAuth token refresh was not resolving proxy configuration per connection. + +**Fix (v3.5.5+):** The token health check scheduler now resolves proxy config per connection before attempting refresh. Update to v3.5.5+. + +### SOCKS5 proxy returns "invalid onRequestStart method" + +**Cause:** On Node.js 22, the undici@8 dispatcher is incompatible with Node's built-in `fetch()` implementation. + +**Fix (v3.5.5+):** OmniRoute now uses undici's own `fetch()` function when a proxy dispatcher is active, ensuring consistent behavior. Update to v3.5.5+. + +--- + +## Provider Issues + +### "Language model did not provide messages" + +**Cause:** Provider quota exhausted. + +**Fix:** + +1. Check dashboard quota tracker +2. Use a combo with fallback tiers +3. Switch to cheaper/free tier + +### Rate Limiting + +**Cause:** Subscription quota exhausted. + +**Fix:** + +- Add fallback: `cc/claude-opus-4-6 → glm/glm-4.7 → if/kimi-k2-thinking` +- Use GLM/MiniMax as cheap backup + +### OAuth Token Expired + +OmniRoute auto-refreshes tokens. If issues persist: + +1. Dashboard → Provider → Reconnect +2. Delete and re-add the provider connection + +### Kiro multi-account: second account invalidates the first + +**Cause:** Kiro's backend enforces a single active session per OIDC client registration. +When two accounts share the same registered client (connections imported before v3.8.0), +refreshing one account's token invalidates the other's refresh token. + +**Fix (v3.8.0+):** Re-import affected connections. +Starting with v3.8.0, every new Kiro connection created via **Import Token**, +**Google/GitHub social login**, or **Auto-Import** automatically registers its own +dedicated OIDC client. The connection is therefore fully isolated and refreshing one +account has no effect on any other account. + +Connections that were imported _before_ v3.8.0 do not carry a per-connection client +registration. Those connections continue to use the shared social-auth refresh endpoint. +To gain isolation, delete the old connection from Dashboard → Providers and re-add it +via any of the three import flows. + +For full details and step-by-step instructions for adding two Kiro accounts side by side, +see [`docs/guides/KIRO_SETUP.md`](../guides/KIRO_SETUP.md). + +--- + +## Cloud Issues + +### Cloud Sync Errors + +1. Verify `BASE_URL` points to your running instance (e.g., `http://localhost:20128`) +2. Verify `CLOUD_URL` points to your cloud endpoint (e.g., `https://omniroute.dev`) +3. Keep `NEXT_PUBLIC_*` values aligned with server-side values + +### Cloud `stream=false` Returns 500 + +**Symptom:** `Unexpected token 'd'...` on cloud endpoint for non-streaming calls. + +**Cause:** Upstream returns SSE payload while client expects JSON. + +**Workaround:** Use `stream=true` for cloud direct calls. Local runtime includes SSE→JSON fallback. + +### Cloud Says Connected but "Invalid API key" + +1. Create a fresh key from local dashboard (`/api/keys`) +2. Run cloud sync: Enable Cloud → Sync Now +3. Old/non-synced keys can still return `401` on cloud + +--- + +## Docker Issues + +### CLI Tool Shows Not Installed + +1. Check runtime fields: `curl http://localhost:20128/api/cli-tools/runtime/codex | jq` +2. For portable mode: use image target `runner-cli` (bundled CLIs) +3. For host mount mode: set `CLI_EXTRA_PATHS` and mount host bin directory as read-only +4. If `installed=true` and `runnable=false`: binary was found but failed healthcheck + +### Quick Runtime Validation + +```bash +curl -s http://localhost:20128/api/cli-tools/codex-settings | jq '{installed,runnable,commandPath,runtimeMode,reason}' +curl -s http://localhost:20128/api/cli-tools/claude-settings | jq '{installed,runnable,commandPath,runtimeMode,reason}' +curl -s http://localhost:20128/api/cli-tools/openclaw-settings | jq '{installed,runnable,commandPath,runtimeMode,reason}' +``` + +--- + +## Cost Issues + +### High Costs + +1. Check usage stats in Dashboard → Usage +2. Switch primary model to GLM/MiniMax +3. Use free tier (Gemini CLI, Qoder) for non-critical tasks +4. Set cost budgets per API key: Dashboard → API Keys → Budget + +--- + +## Debugging + +### Enable Log Files + +Set `APP_LOG_TO_FILE=true` in your `.env` file. Application logs are written under `logs/`. +Request artifacts are stored under `${DATA_DIR}/call_logs/` when the call log pipeline is +enabled in settings. +When pipeline capture is enabled, set `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS=false` to omit +stream chunk payloads, or tune `CALL_LOG_PIPELINE_MAX_SIZE_KB` to change the artifact cap in KB. + +### Check Provider Health + +```bash +# Health dashboard +http://localhost:20128/dashboard/health + +# API health check +curl http://localhost:20128/api/monitoring/health +``` + +### Runtime Storage + +- Main state: `${DATA_DIR}/storage.sqlite` (providers, combos, aliases, keys, settings) +- Usage: SQLite tables in `storage.sqlite` (`usage_history`, `call_logs`, `proxy_logs`) + optional `${DATA_DIR}/call_logs/` +- Application logs: `/logs/...` (when `APP_LOG_TO_FILE=true`) +- Call log artifacts: `${DATA_DIR}/call_logs/YYYY-MM-DD/...` when the call log pipeline is enabled + +--- + +## Circuit Breaker Issues + +### Provider stuck in OPEN state + +When a provider's circuit breaker is OPEN, requests are blocked until the cooldown expires. + +**Fix:** + +1. Go to **Dashboard → Settings → Resilience** +2. Check the circuit breaker card for the affected provider +3. Click **Reset All** to clear all breakers, or wait for the cooldown to expire +4. Verify the provider is actually available before resetting + +### Provider keeps tripping the circuit breaker + +If a provider repeatedly enters OPEN state: + +1. Check **Dashboard → Health → Provider Health** for the failure pattern +2. Go to **Settings → Resilience → Provider Profiles** and increase the failure threshold +3. Check if the provider has changed API limits or requires re-authentication +4. Review latency telemetry — high latency may cause timeout-based failures + +--- + +## Audio Transcription Issues + +### "Unsupported model" error + +- Ensure you're using the correct prefix: `deepgram/nova-3` or `assemblyai/best` +- Verify the provider is connected in **Dashboard → Providers** + +### Transcription returns empty or fails + +- Check supported audio formats: `mp3`, `wav`, `m4a`, `flac`, `ogg`, `webm` +- Verify file size is within provider limits (typically < 25MB) +- Check provider API key validity in the provider card + +--- + +## Translator Debugging + +Use **Dashboard → Translator** to debug format translation issues: + +| Mode | When to Use | +| ---------------- | -------------------------------------------------------------------------------------------- | +| **Playground** | Compare input/output formats side by side — paste a failing request to see how it translates | +| **Chat Tester** | Send live messages and inspect the full request/response payload including headers | +| **Test Bench** | Run batch tests across format combinations to find which translations are broken | +| **Live Monitor** | Watch real-time request flow to catch intermittent translation issues | + +### Common format issues + +- **Thinking tags not appearing** — Check if the target provider supports thinking and the thinking budget setting +- **Tool calls dropping** — Some format translations may strip unsupported fields; verify in Playground mode +- **System prompt missing** — Claude and Gemini handle system prompts differently; check translation output +- **SDK returns raw string instead of object** — Resolved in v1.x; response sanitizer strips non-standard fields (`x_groq`, `usage_breakdown`, etc.) that cause OpenAI SDK Pydantic validation failures. If you still see this on v3.x+, please file an issue. +- **GLM/ERNIE rejects `system` role** — Resolved in v1.x; role normalizer automatically merges system messages into user messages for incompatible models. If you still see this on v3.x+, please file an issue. +- **`developer` role not recognized** — Resolved in v1.x; automatically converted to `system` for non-OpenAI providers. If you still see this on v3.x+, please file an issue. +- **`json_schema` not working with Gemini** — Resolved in v1.x; `response_format` is now converted to Gemini's `responseMimeType` + `responseSchema`. If you still see this on v3.x+, please file an issue. + +--- + +## Resilience Settings + +### Auto rate-limit not triggering + +- Auto rate-limit only applies to API key providers (not OAuth/subscription) +- Verify **Settings → Resilience → Provider Profiles** has auto-rate-limit enabled +- Check if the provider returns `429` status codes or `Retry-After` headers + +### Tuning exponential backoff + +Provider profiles support these settings: + +- **Base delay** — Initial wait time after first failure (default: 1s) +- **Max delay** — Maximum wait time cap (default: 30s) +- **Multiplier** — How much to increase delay per consecutive failure (default: 2x) + +### Anti-thundering herd + +When many concurrent requests hit a rate-limited provider, OmniRoute uses mutex + auto rate-limiting to serialize requests and prevent cascading failures. This is automatic for API key providers. + +--- + +## Optional RAG / LLM failure taxonomy (16 problems) + +Some OmniRoute users place the gateway in front of RAG or agent stacks. In those setups it is common to see a strange pattern: OmniRoute looks healthy (providers up, routing profiles ok, no rate limit alerts) but the final answer is still wrong. + +In practice these incidents usually come from the downstream RAG pipeline, not from the gateway itself. + +If you want a shared vocabulary to describe those failures you can use the WFGY ProblemMap, an external MIT license text resource that defines sixteen recurring RAG / LLM failure patterns. At a high level it covers: + +- retrieval drift and broken context boundaries +- empty or stale indexes and vector stores +- embedding versus semantic mismatch +- prompt assembly and context window issues +- logic collapse and overconfident answers +- long chain and agent coordination failures +- multi agent memory and role drift +- deployment and bootstrap ordering problems + +The idea is simple: + +1. When you investigate a bad response, capture: + - user task and request + - route or provider combo in OmniRoute + - any RAG context used downstream (retrieved documents, tool calls, etc) +2. Map the incident to one or two WFGY ProblemMap numbers (`No.1` … `No.16`). +3. Store the number in your own dashboard, runbook, or incident tracker next to the OmniRoute logs. +4. Use the corresponding WFGY page to decide whether you need to change your RAG stack, retriever, or routing strategy. + +Full text and concrete recipes live here (MIT license, text only): + +[WFGY ProblemMap README](https://github.com/onestardao/WFGY/blob/main/ProblemMap/README.md) + +You can ignore this section if you do not run RAG or agent pipelines behind OmniRoute. + +--- + +## v3.8.0 Known Issues + +Issues specific to the v3.8.0 release and their current workarounds. If a fix lands in a later patch, the entry will be updated or removed. + +### Windsurf OAuth flow fails with 401 + +**Symptoms:** + +- "401 unauthorized" while completing the Windsurf OAuth flow from the dashboard +- Windsurf provider card stays in "needs reconnection" state after the callback + +**Causes:** + +- `WINDSURF_FIREBASE_API_KEY` env var missing or empty +- `WINDSURF_API_KEY` misconfigured or pointing at a stale token +- Local firewall/proxy blocking the OAuth callback + +**Fix:** + +1. Verify both `WINDSURF_FIREBASE_API_KEY` and `WINDSURF_API_KEY` are set in `.env` +2. Restart OmniRoute so the new env values are picked up +3. Re-run the OAuth flow from **Dashboard → Providers → Windsurf → Reconnect** + +### Devin CLI auth failures + +**Symptoms:** + +- "Devin CLI not found" or "auth failed" when invoking Devin-backed tools +- CLI runtime check reports `installed=false` + +**Causes:** + +- `CLI_DEVIN_BIN` points to a path that does not exist +- Devin CLI is not installed on the host + +**Fix:** + +1. Install the Devin CLI for your platform +2. Set `CLI_DEVIN_BIN=/usr/local/bin/devin` (or the real path) in `.env` +3. Restart OmniRoute and re-test from **Dashboard → CLI Tools** + +### Model cooldown stuck (manual reset) + +**Symptoms:** + +- A model stays listed in cooldown even after the expiration time has passed +- Requests still skip the model in combo routing despite the timestamp being in the past + +**Manual reset:** + +- **Dashboard:** **Settings → Model Cooldowns** → click **Re-enable** on the affected card +- **API:** `DELETE /api/resilience/model-cooldowns` with management auth headers + +### Command Code provider connection fails with 403 + +**Symptoms:** + +- 403 when testing the Command Code provider connection +- The provider card shows "unauthorized" after a fresh add + +**Cause:** The OAuth flow did not complete (callback not received or token not persisted). + +**Fix:** + +- Run `omniroute providers` from the CLI to re-trigger the OAuth flow, or +- Re-run OAuth from **Dashboard → Providers → Command Code → Reconnect** + +### ModelScope returns aggressive 429 cooldowns + +**Symptoms:** + +- Very short or immediate cooldowns on ModelScope after a small burst of requests +- Combo routing skips ModelScope earlier than expected + +**Cause:** ModelScope emits provider-specific `Retry-After` headers. v3.8.0 ships dedicated handling for those headers, so older versions misread them as generic rate-limit hints. + +**Fix:** + +- Ensure you are on v3.8.0 or later +- Verify the `useUpstream429BreakerHints` toggle is enabled under **Settings → Resilience** + +### OMNIROUTE_WS_BRIDGE_SECRET missing in production + +**Symptoms:** + +- 401 on every Codex/Responses WebSocket bridge request when running on a remote production host +- WebSocket bridge handshake closes immediately after connect + +**Cause:** The `OMNIROUTE_WS_BRIDGE_SECRET` env var is missing from the production environment. + +**Fix:** + +1. Generate a random secret: `openssl rand -hex 32` +2. Set `OMNIROUTE_WS_BRIDGE_SECRET=` in the production server env (and any client that talks to the bridge) +3. Restart OmniRoute + +### Responses API: background mode degraded to synchronous + +**Symptoms:** + +- Warning logged: `background mode degraded to synchronous` +- A `background: true` request returns a normal synchronous response instead of a background job handle + +**Cause:** v3.8.0 intentionally degrades `background: true` on the Responses API to synchronous execution while emitting a warning. Full async background execution is a future deliverable. + +**Fix:** + +- Adjust the client to call without `background`, or +- Wait for a later release that ships full async background mode (track the changelog) + +--- + +## Still Stuck? + +- **GitHub Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues) +- **Architecture**: See [`docs/architecture/ARCHITECTURE.md`](../architecture/ARCHITECTURE.md) for internal details +- **API Reference**: See [`docs/reference/API_REFERENCE.md`](../reference/API_REFERENCE.md) for all endpoints +- **Health Dashboard**: Check **Dashboard → Health** for real-time system status +- **Translator**: Use **Dashboard → Translator** to debug format issues diff --git a/docs/getting-started/meta.json b/docs/getting-started/meta.json new file mode 100644 index 000000000000..33d94a228cb4 --- /dev/null +++ b/docs/getting-started/meta.json @@ -0,0 +1,11 @@ +{ + "title": "Getting Started", + "description": "Get started with OmniRoute in minutes — no technical background needed", + "pages": [ + "QUICK-START", + "AUTO-COMBO-GUIDE", + "PROVIDERS-GUIDE", + "FREE-TIERS-GUIDE", + "TROUBLESHOOTING" + ] +} diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index 0a7161cfaf6a..01e197fe07e2 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -6,12 +6,38 @@ lastUpdated: 2026-05-13 # Troubleshooting +> **For Users**: Looking for quick fixes? See the [Quick Reference](#quick-reference) below. + 🌐 **Languages:** 🇺🇸 [English](./TROUBLESHOOTING.md) | 🇧🇷 [Português (Brasil)](../i18n/pt-BR/docs/guides/TROUBLESHOOTING.md) | 🇪🇸 [Español](../i18n/es/docs/guides/TROUBLESHOOTING.md) | 🇫🇷 [Français](../i18n/fr/docs/guides/TROUBLESHOOTING.md) | 🇮🇹 [Italiano](../i18n/it/docs/guides/TROUBLESHOOTING.md) | 🇷🇺 [Русский](../i18n/ru/docs/guides/TROUBLESHOOTING.md) | 🇨🇳 [中文 (简体)](../i18n/zh-CN/docs/guides/TROUBLESHOOTING.md) | 🇩🇪 [Deutsch](../i18n/de/docs/guides/TROUBLESHOOTING.md) | 🇮🇳 [हिन्दी](../i18n/in/docs/guides/TROUBLESHOOTING.md) | 🇹🇭 [ไทย](../i18n/th/docs/guides/TROUBLESHOOTING.md) | 🇺🇦 [Українська](../i18n/uk-UA/docs/guides/TROUBLESHOOTING.md) | 🇸🇦 [العربية](../i18n/ar/docs/guides/TROUBLESHOOTING.md) | 🇯🇵 [日本語](../i18n/ja/docs/guides/TROUBLESHOOTING.md) | 🇻🇳 [Tiếng Việt](../i18n/vi/docs/guides/TROUBLESHOOTING.md) | 🇧🇬 [Български](../i18n/bg/docs/guides/TROUBLESHOOTING.md) | 🇩🇰 [Dansk](../i18n/da/docs/guides/TROUBLESHOOTING.md) | 🇫🇮 [Suomi](../i18n/fi/docs/guides/TROUBLESHOOTING.md) | 🇮🇱 [עברית](../i18n/he/docs/guides/TROUBLESHOOTING.md) | 🇭🇺 [Magyar](../i18n/hu/docs/guides/TROUBLESHOOTING.md) | 🇮🇩 [Bahasa Indonesia](../i18n/id/docs/guides/TROUBLESHOOTING.md) | 🇰🇷 [한국어](../i18n/ko/docs/guides/TROUBLESHOOTING.md) | 🇲🇾 [Bahasa Melayu](../i18n/ms/docs/guides/TROUBLESHOOTING.md) | 🇳🇱 [Nederlands](../i18n/nl/docs/guides/TROUBLESHOOTING.md) | 🇳🇴 [Norsk](../i18n/no/docs/guides/TROUBLESHOOTING.md) | 🇵🇹 [Português (Portugal)](../i18n/pt/docs/guides/TROUBLESHOOTING.md) | 🇷🇴 [Română](../i18n/ro/docs/guides/TROUBLESHOOTING.md) | 🇵🇱 [Polski](../i18n/pl/docs/guides/TROUBLESHOOTING.md) | 🇸🇰 [Slovenčina](../i18n/sk/docs/guides/TROUBLESHOOTING.md) | 🇸🇪 [Svenska](../i18n/sv/docs/guides/TROUBLESHOOTING.md) | 🇵🇭 [Filipino](../i18n/phi/docs/guides/TROUBLESHOOTING.md) | 🇨🇿 [Čeština](../i18n/cs/docs/guides/TROUBLESHOOTING.md) Common problems and solutions for OmniRoute. --- +## Quick Reference + +**New to OmniRoute?** Start here — these solve 90% of problems: + +| I see this | What it means | What to do | +|------------|--------------|------------| +| "Can't connect" | OmniRoute isn't running | Run `omniroute` or `docker restart omniroute` | +| "Invalid API key" | Your key is wrong or expired | Re-copy the key from the provider's website | +| "Rate limit exceeded" | You're sending too many requests | Wait 1 minute, or use `model: "auto"` for automatic fallback | +| "Quota exceeded" | You've used up your free/paid quota | Connect more providers, or use free providers (Kiro, Pollinations) | +| "Slow responses" | Provider is busy or far away | Use `model: "auto/fast"` or connect a faster provider (Groq, Cerebras) | +| "Wrong provider used" | `auto` picked a different provider | That's normal! `auto` picks the best one. Force a specific provider with `model: "openai/gpt-4o"` | +| "502 Bad Gateway" | Provider is down | Wait and retry, or use `model: "auto"` to switch providers | +| "401 Unauthorized" | Your credentials are wrong | Check your API key or re-authenticate with OAuth | +| "429 Too Many Requests" | Rate limited | Wait 1 minute, or connect more providers | + +**Still stuck?** See the [detailed troubleshooting](#detailed-troubleshooting) below, or ask on [Discord](https://discord.gg/hmexnhgE). + +--- + +## Detailed Troubleshooting + +--- + ## Quick Fixes | Problem | Solution | diff --git a/docs/guides/meta.json b/docs/guides/meta.json index 67a106dfe621..6603dc13781d 100644 --- a/docs/guides/meta.json +++ b/docs/guides/meta.json @@ -1,5 +1,6 @@ { "title": "Guides", + "description": "Technical guides for operators and developers", "pages": [ "SETUP_GUIDE", "USER_GUIDE", @@ -10,7 +11,6 @@ "KIRO_SETUP", "PWA_GUIDE", "TERMUX_GUIDE", - "TROUBLESHOOTING", "UNINSTALL" ] } diff --git a/docs/meta.json b/docs/meta.json index 6f4ed3194c22..5540e6a7e0d3 100644 --- a/docs/meta.json +++ b/docs/meta.json @@ -1,12 +1,12 @@ { "pages": [ - "guides", - "architecture", - "reference", - "frameworks", - "routing", - "security", - "compression", - "ops" + { + "title": "For Non-Tech Users", + "pages": ["getting-started", "guides"] + }, + { + "title": "For Tech Users", + "pages": ["architecture", "reference", "frameworks", "routing", "security", "compression", "ops"] + } ] } diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index dc5712fc42a6..669c25590b72 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -992,6 +992,341 @@ Returns the public A2A agent card (name, description, capabilities, skill catalo --- +## ACP (Agent Client Protocol) Management + +The ACP framework lets you spawn CLI agents (Claude Code, Codex, Gemini CLI, etc.) +as child processes. These endpoints manage ACP agent detection and custom agent +registration. + +| Method | Path | Description | +| ------ | ----------------------- | ---------------------------------------------------------------------------------------- | +| GET | `/api/acp/agents` | List all known CLI agents (built-in + custom) with installation status, version, binary | +| POST | `/api/acp/agents` | Register a custom ACP agent — body: `{id, name, binary, versionCommand, providerAlias, spawnArgs, protocol}` | +| DELETE | `/api/acp/agents/[id]` | Remove a custom ACP agent | +| POST | `/api/acp/agents/refresh` | Force refresh of the agent detection cache (60s TTL) | + +**Response example** (`GET /api/acp/agents`): + +```json +{ + "agents": [ + { + "id": "claude", + "name": "Claude Code CLI", + "binary": "claude", + "version": "1.0.45", + "installed": true, + "protocol": "stdio", + "providerAlias": "claude", + "isCustom": false + }, + { + "id": "my-custom-cli", + "name": "My Custom CLI", + "installed": false, + "protocol": "stdio", + "providerAlias": "my-provider", + "isCustom": true + } + ], + "cacheTtlMs": 60000, + "cacheAge": 1234 +} +``` + +**Auth:** Requires management session (dashboard `auth_token` cookie) or a +management-scoped API key. + +See [ACP Framework](../frameworks/ACP.md) for full details. + +--- + +## Analytics & Observability + +Real-time analytics endpoints for monitoring routing, compression, and provider +diversity. These power the `/dashboard/analytics/*` pages. + +### Auto-routing analytics + +| Method | Path | Description | +| ------ | ----------------------------------- | -------------------------------------------------------------------------------------------- | +| GET | `/api/analytics/auto-routing` | Aggregate auto-routing stats: total calls, strategy distribution, tier distribution, top providers | +| GET | `/api/analytics/auto-routing?days=7` | Time-windowed stats (default 24h) | + +**Response example**: + +```json +{ + "window": "24h", + "totalCalls": 1234, + "strategyBreakdown": { + "rules": 800, + "cost": 200, + "latency": 150, + "sla-aware": 50, + "lkgp": 34 + }, + "tierBreakdown": { + "ultra": 100, + "pro": 500, + "standard": 400, + "free": 234 + }, + "topProviders": [ + { "provider": "openai", "calls": 500, "avgLatencyMs": 850 }, + { "provider": "anthropic", "calls": 300, "avgLatencyMs": 1200 } + ] +} +``` + +### Compression analytics + +| Method | Path | Description | +| ------ | --------------------------------- | ------------------------------------------------------------------------------------ | +| GET | `/api/analytics/compression` | Aggregate compression stats: tokens saved, savings %, mode distribution, engine usage | + +**Response example**: + +```json +{ + "window": "24h", + "totalOriginalTokens": 5000000, + "totalCompressedTokens": 3500000, + "totalSavings": 1500000, + "savingsPct": 30.0, + "modeBreakdown": { + "lite": 400, + "standard": 600, + "aggressive": 100, + "ultra": 50, + "rtk": 84 + }, + "engineBreakdown": { + "caveman": 800, + "rtk": 434 + } +} +``` + +### Provider diversity tracking + +| Method | Path | Description | +| ------ | --------------------------------- | -------------------------------------------------------------------------------------------- | +| GET | `/api/analytics/diversity` | Shannon entropy-based diversity tracking: prevents single points of failure by measuring provider spread | + +**Response example**: + +```json +{ + "window": "24h", + "shannonEntropy": 2.45, + "maxEntropy": 3.17, + "diversityRatio": 0.77, + "providerUsage": { + "openai": 0.40, + "anthropic": 0.25, + "google": 0.20, + "kiro": 0.15 + }, + "warnings": [ + "OpenAI accounts for 40% of traffic — consider diversifying" + ] +} +``` + +**Auth:** Requires management session or management-scoped API key. + +--- + +## Admin Operations + +Admin-only endpoints for operational management. + +| Method | Path | Description | +| ------ | ------------------------------- | ---------------------------------------------------------------------------------------------- | +| GET | `/api/admin/concurrency` | Read current concurrency limits (global + per-provider) | +| POST | `/api/admin/concurrency` | Update concurrency limits — body: `{global?: number, perProvider?: Record}` | +| GET | `/api/admin/circuit-breaker` | Read circuit breaker states for all providers | +| POST | `/api/admin/circuit-breaker/reset` | Manually reset a circuit breaker — body: `{providerId}` | +| GET | `/api/admin/rate-limits` | Read current rate limit configurations | +| POST | `/api/admin/rate-limits` | Update rate limit configs — body: `{providerId, requestsPerMinute, tokensPerMinute}` | + +**Auth:** Requires management session with admin scope. + +--- + +## CLI Tools Management + +Manage CLI tools that integrate with OmniRoute (antigravity, chipotle, commandCode, +devin-cli, etc.). See [Provider Reference](./PROVIDER_REFERENCE.md) for the full list. + +| Method | Path | Description | +| ------ | --------------------------------- | -------------------------------------------------------------------------------------------- | +| GET | `/api/cli-tools/all-statuses` | Status of all CLI tools (installed, version, last seen) | +| GET | `/api/cli-tools/[id]/status` | Status of a specific CLI tool (id can be: antigravity, chipotle, commandCode, devin-cli, etc.) | +| POST | `/api/cli-tools/apply` | Apply a CLI tool configuration to a provider connection | +| GET | `/api/cli-tools/backups` | List CLI tool configuration backups | +| POST | `/api/cli-tools/backups` | Create a backup of all CLI tool configurations | +| POST | `/api/cli-tools/[id]/restore` | Restore a CLI tool from a backup | +| GET | `/api/cli-tools/antigravity-mitm` | Antigravity MITM proxy status (the "antigravity-mitm" CLI tool) | +| POST | `/api/cli-tools/antigravity-mitm/alias` | Configure antigravity-mitm aliases | + +**Auth:** Requires management session. + +--- + +## Agent Skills + +Manage AI agent skills (similar to OpenAI's custom GPTs but for agents). + +| Method | Path | Description | +| ------ | --------------------------------- | -------------------------------------------------------------------------------------------- | +| GET | `/api/agent-skills` | List all agent skills (built-in + custom) | +| GET | `/api/agent-skills/[id]` | Get a specific agent skill | +| POST | `/api/agent-skills` | Create a custom agent skill — body: `{name, description, prompt, model?, temperature?}` | +| PUT | `/api/agent-skills/[id]` | Update a custom agent skill | +| DELETE | `/api/agent-skills/[id]` | Delete a custom agent skill | +| GET | `/api/agent-skills/[id]/raw` | Get raw prompt + metadata (no execution) | +| POST | `/api/agent-skills/generate` | AI-generate a new skill from a natural language description | + +**Auth:** Requires management session or management-scoped API key. + +--- + +## Cache Management + +Manage the semantic cache and reasoning cache. + +| Method | Path | Description | +| ------ | --------------------------------- | -------------------------------------------------------------------------------------------- | +| GET | `/api/cache` | Cache overview: total entries, hit rate, size on disk | +| GET | `/api/cache/entries` | List cached entries (with pagination) | +| DELETE | `/api/cache/entries` | Delete cache entries (filter by query parameters) | +| GET | `/api/cache/stats` | Detailed cache statistics (per-provider, per-model) | +| GET | `/api/cache/reasoning` | Reasoning cache status (for reasoning replay) | +| POST | `/api/cache/reasoning/clear` | Clear reasoning cache | +| POST | `/api/cache/clear` | Clear all cache entries | + +**Auth:** Requires management session. + +--- + +## Memory System + +Manage persistent memory (FTS5 + vector embeddings). + +| Method | Path | Description | +| ------ | --------------------------------- | -------------------------------------------------------------------------------------------- | +| GET | `/api/memory` | List memory entries (filter by scope, type, search query) | +| POST | `/api/memory` | Create a new memory entry — body: `{scope, type, content, metadata?}` | +| GET | `/api/memory/[id]` | Get a specific memory entry | +| PUT | `/api/memory/[id]` | Update a memory entry | +| DELETE | `/api/memory/[id]` | Delete a memory entry | +| GET | `/api/memory/search` | Search memory (FTS5 + vector) | +| POST | `/api/memory/clear` | Clear memory entries (with filters) | +| GET | `/api/memory/stats` | Memory statistics (total entries, embedding coverage, etc.) | + +**Auth:** Requires management session or management-scoped API key. + +--- + +## Webhooks + +Manage webhook subscriptions for events. + +| Method | Path | Description | +| ------ | --------------------------------- | -------------------------------------------------------------------------------------------- | +| GET | `/api/webhooks` | List all webhook subscriptions | +| POST | `/api/webhooks` | Create a webhook subscription — body: `{url, events[], secret?, active?}` | +| GET | `/api/webhooks/[id]` | Get a specific webhook subscription | +| PUT | `/api/webhooks/[id]` | Update a webhook subscription | +| DELETE | `/api/webhooks/[id]` | Delete a webhook subscription | +| GET | `/api/webhooks/events` | List all available webhook event types | +| GET | `/api/webhooks/[id]/deliveries` | List delivery history for a webhook (success/failure log) | +| POST | `/api/webhooks/[id]/test` | Send a test event to a webhook | + +**Auth:** Requires management session. + +See [Webhooks Framework](../frameworks/WEBHOOKS.md) for full event types. + +--- + +## Skills Framework + +Manage Skills (the agentic extensions framework). + +| Method | Path | Description | +| ------ | --------------------------------- | -------------------------------------------------------------------------------------------- | +| GET | `/api/skills` | List all installed skills (built-in + custom) | +| POST | `/api/skills/install` | Install a skill from a local path or URL | +| DELETE | `/api/skills/[id]` | Uninstall a skill | +| POST | `/api/skills/[id]/enable` | Enable a disabled skill | +| POST | `/api/skills/[id]/disable` | Disable an enabled skill | +| POST | `/api/skills/[id]/execute` | Execute a skill with input | +| GET | `/api/skills/[id]/executions` | List execution history for a skill | + +**Auth:** Requires management session or management-scoped API key. + +See [Skills Framework](../frameworks/SKILLS.md) for full details. + +--- + +## Plugins + +Manage OmniRoute plugins (third-party extensions). + +| Method | Path | Description | +| ------ | --------------------------------- | -------------------------------------------------------------------------------------------- | +| GET | `/api/plugins` | List installed plugins | +| POST | `/api/plugins/install` | Install a plugin from a local path or URL | +| DELETE | `/api/plugins/[id]` | Uninstall a plugin | +| POST | `/api/plugins/[id]/enable` | Enable a disabled plugin | +| POST | `/api/plugins/[id]/disable` | Disable an enabled plugin | +| GET | `/api/plugins/[id]/config` | Get plugin configuration | +| PUT | `/api/plugins/[id]/config` | Update plugin configuration | + +**Auth:** Requires management session. + +See [Plugins Framework](../plugins/PLUGIN_SDK.md) for full details. + +--- + +## Shadow Routing + +Beta-test new providers without affecting production traffic. + +| Method | Path | Description | +| ------ | --------------------------------- | -------------------------------------------------------------------------------------------- | +| GET | `/api/shadow` | List all shadow routing rules | +| POST | `/api/shadow` | Create a shadow routing rule — body: `{providerId, shadowProviderId, trafficPct, duration?}` | +| DELETE | `/api/shadow/[id]` | Delete a shadow routing rule | +| GET | `/api/shadow/[id]/results` | Get shadow routing results (comparison metrics between real and shadow traffic) | +| GET | `/api/shadow/metrics` | Aggregate shadow routing metrics across all rules | + +**Auth:** Requires management session. + +--- + +## Guardrails + +Manage runtime guardrails (PII detection, prompt injection detection, vision bridging). + +| Method | Path | Description | +| ------ | --------------------------------- | -------------------------------------------------------------------------------------------- | +| GET | `/api/guardrails` | List all guardrails and their status (enabled/disabled) | +| POST | `/api/guardrails/[id]/enable` | Enable a guardrail | +| POST | `/api/guardrails/[id]/disable` | Disable a guardrail | +| GET | `/api/guardrails/logs` | Get guardrail trigger logs (PII detections, injection attempts, etc.) | +| POST | `/api/guardrails/test` | Test a guardrail against a sample input | + +**Auth:** Requires management session. + +See [Security > Guardrails](../security/GUARDRAILS.md) for full details. + +--- + +--- + ## Authentication - Dashboard routes (`/dashboard/*`) use `auth_token` cookie diff --git a/docs/reference/FREE_TIERS.md b/docs/reference/FREE_TIERS.md index 69dde3c47c9a..4674314374af 100644 --- a/docs/reference/FREE_TIERS.md +++ b/docs/reference/FREE_TIERS.md @@ -6,6 +6,8 @@ lastUpdated: 2026-06-05 # Free Tiers & Free-Token Budget +> **For Users**: Looking for a simple guide? See the [Free Tiers Guide](../getting-started/FREE-TIERS-GUIDE.md) for step-by-step instructions on getting free AI. + > **Last researched:** 2026-06-05 — per-provider web research of current free-tier quotas + ToS (98 providers). > **Source of truth (catalog):** `src/shared/constants/providers.ts` (`hasFree: true` + `freeNote`). The token-budget numbers below come from live web research and are an **approximation** — see [Methodology & caveats](#methodology--caveats). diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 246e05ce3768..7170e7c15fd4 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -6,6 +6,8 @@ lastUpdated: 2026-06-06 # Provider Reference +> **For Users**: Looking for a simple guide? See the [Providers Guide](../getting-started/PROVIDERS-GUIDE.md) for step-by-step instructions. + > **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand. > Regenerate with: `npm run gen:provider-reference` > **Last generated:** 2026-06-06 diff --git a/docs/routing/AUTO-COMBO.md b/docs/routing/AUTO-COMBO.md index 18a78c609848..c470a407d070 100644 --- a/docs/routing/AUTO-COMBO.md +++ b/docs/routing/AUTO-COMBO.md @@ -6,6 +6,8 @@ lastUpdated: 2026-05-13 # OmniRoute Auto-Combo Engine +> **For Users**: Looking for a quick start? See the [Auto-Combo User Guide](../getting-started/AUTO-COMBO-GUIDE.md) for simple explanations and examples. + > Self-managing model chains with adaptive scoring + zero-config auto-routing ## Zero-Config Auto-Routing (`auto/` prefix) @@ -107,11 +109,11 @@ Four pre-defined weight profiles in `open-sse/services/autoCombo/modePacks.ts`. | Factor | ship-fast | cost-saver | quality-first | offline-friendly | | :----------- | :-------- | :--------- | :------------ | :--------------- | -| quota | 0.15 | 0.15 | 0.10 | **0.40** | -| health | 0.30 | 0.20 | 0.20 | 0.30 | -| costInv | 0.05 | **0.40** | 0.05 | 0.10 | -| latencyInv | **0.35** | 0.05 | 0.05 | 0.05 | -| taskFit | 0.10 | 0.10 | **0.40** | 0.00 | +| quota | 0.14 | 0.14 | 0.10 | **0.37** | +| health | 0.28 | 0.19 | 0.18 | 0.28 | +| costInv | 0.05 | **0.37** | 0.05 | 0.10 | +| latencyInv | **0.32** | 0.05 | 0.05 | 0.05 | +| taskFit | 0.10 | 0.10 | **0.37** | 0.00 | | stability | 0.00 | 0.05 | 0.15 | 0.10 | | tierPriority | 0.05 | 0.05 | 0.05 | 0.05 | @@ -119,10 +121,10 @@ Notes: - `tierAffinity` and `specificityMatch` are not set in mode packs — `calculateScore()` treats them as `?? 0` when absent. - Each pack's emphasis at a glance: - - **ship-fast** → latencyInv 0.35 + health 0.30 (low-latency, healthy connections) - - **cost-saver** → costInv 0.40 (cheapest tokens win) - - **quality-first** → taskFit 0.40 + stability 0.15 (best model for the task, consistent) - - **offline-friendly** → quota 0.40 + health 0.30 (max headroom regardless of speed/cost) + - **ship-fast** → latencyInv 0.32 + health 0.28 (low-latency, healthy connections) + - **cost-saver** → costInv 0.37 (cheapest tokens win) + - **quality-first** → taskFit 0.37 + stability 0.15 (best model for the task, consistent) + - **offline-friendly** → quota 0.37 + health 0.28 (max headroom regardless of speed/cost) ## All Routing Strategies @@ -205,6 +207,227 @@ Persisted `strategy: "auto"` combos can set `config.routerStrategy` (or legacy cost SLOs - `lkgp` — last known good provider first +### Router strategies in detail + +The auto-combo engine exposes 5 pluggable **RouterStrategy** implementations that +you can swap via `config.routerStrategy` (or the legacy `config.auto.routerStrategy`). +Each strategy picks one provider from the candidate pool, given a `RoutingContext` +(task type, tool/vision hints, token estimate, optional SLA policy, optional +last-known-good provider). + +#### 1. `rules` (default) — 6-factor weighted scoring + +Wraps the existing scoring engine. Filters out `OPEN` circuit-breaker +candidates, then runs `scorePool()` with the current task type and `getTaskFitness()`, +picking the top-scoring provider. + +```ts +class RulesStrategyImpl implements RouterStrategy { + readonly name = "rules"; + readonly description = + "6-factor weighted scoring: quota, health, cost, latency, taskFit, stability"; + + select(pool, context) { + const eligible = pool.filter((c) => c.circuitBreakerState !== "OPEN"); + const ranked = scorePool(eligible.length > 0 ? eligible : pool, context.taskType, undefined, getTaskFitness); + return { provider: ranked[0].provider, /* ... */ }; + } +} +``` + +**When to use**: Default. Use when you want a balanced trade-off across all signals. + +**Alias**: `rules` (no alias) + +--- + +#### 2. `cost` / `eco` — cheapest healthy provider + +Sorts the candidate pool by `costPer1MTokens` (ascending) and picks the cheapest. +Filters out `OPEN` candidates first. + +```ts +class CostStrategyImpl implements RouterStrategy { + readonly name = "cost"; + readonly description = "Always selects cheapest available provider"; + + select(pool, context) { + const healthy = pool.filter((c) => c.circuitBreakerState !== "OPEN"); + const sorted = [...healthy].sort((a, b) => a.costPer1MTokens - b.costPer1MTokens); + return { provider: sorted[0].provider, /* ... */ }; + } +} +``` + +**When to use**: Cost-sensitive workloads, batch processing, or background jobs. + +**Aliases**: `cost`, `eco` + +--- + +#### 3. `latency` / `fast` — lowest p95 latency with reliability penalty + +Sorts by `p95LatencyMs + (errorRate * 1000)`. The error-rate penalty ensures +unreliable providers are ranked lower even if their nominal latency is low. + +```ts +class LatencyStrategyImpl implements RouterStrategy { + readonly name = "latency"; + readonly description = "Prioritizes lowest p95 latency with reliability weighting"; + + select(pool, context) { + const healthy = pool.filter((c) => c.circuitBreakerState !== "OPEN"); + const sorted = [...healthy].sort((a, b) => + (a.p95LatencyMs + a.errorRate * 1000) - (b.p95LatencyMs + b.errorRate * 1000) + ); + return { provider: sorted[0].provider, /* ... */ }; + } +} +``` + +**When to use**: Latency-sensitive workloads like real-time chat, autocomplete, or +interactive coding assistants. + +**Aliases**: `latency`, `fast` + +--- + +#### 4. `sla-aware` / `sla` — latency/error/cost SLO compliance + +Scores each candidate by how well it satisfies the configured SLO policy: + +| Factor | Weight | Formula | +|--------|--------|---------| +| Latency score | 35% | `threshold / max(value, ε)` | +| Error score | 35% | `threshold / max(value, ε)` | +| Health score | 15% | `1.0` (CLOSED) / `0.5` (HALF_OPEN) / `0.0` (OPEN) | +| Cost score | 10% | `threshold / max(value, ε)` or inverse normalized | +| Stability score | 5% | inverse normalized latency stddev | + +When `hardConstraints: true`, candidates are sorted primarily by **violation score** +(how far they exceed any SLO), then by composite score. Otherwise it's just +the composite score. + +```ts +class SLAStrategyImpl implements RouterStrategy { + readonly name = "sla-aware"; + readonly description = "Selects the provider most likely to satisfy latency, error-rate, and cost SLOs"; + + select(pool, context) { + // ... scores each candidate against policy: { targetP95Ms, maxErrorRate, maxCostPer1MTokens, hardConstraints } + } +} +``` + +**SLA fields** (set on the combo config): + +```json +{ + "strategy": "auto", + "config": { + "routerStrategy": "sla-aware", + "slaTargetP95Ms": 1500, + "slaMaxErrorRate": 0.05, + "slaMaxCostPer1MTokens": 5, + "slaHardConstraints": true + } +} +``` + +**When to use**: Production workloads with strict latency, error-rate, or cost budgets. + +**Aliases**: `sla-aware`, `sla` + +--- + +#### 5. `lkgp` — last known good provider first + +Tries the **last known good provider** (if set) first, then falls back to the +`rules` strategy. Useful for session stickiness — the same provider handles +follow-up requests in a conversation. + +```ts +class LKGPStrategyImpl implements RouterStrategy { + readonly name = "lkgp"; + readonly description = "Tries last known good provider first, then falls back to rules"; + + select(pool, context) { + if (context.lkgpEnabled === false) { + return getStrategy("rules").select(pool, context); + } + + if (context.lastKnownGoodProvider) { + const candidates = pool.filter( + (c) => c.provider === context.lastKnownGoodProvider && c.circuitBreakerState !== "OPEN" + ); + if (candidates.length > 0) { + return { provider: candidates[0].provider, /* ... */ }; + } + } + + // Fallback to rules strategy + return getStrategy("rules").select(pool, context); + } +} +``` + +**When to use**: Multi-turn conversations where you want the same provider to handle +follow-up requests (e.g., for caching, context continuity, or pricing consistency). + +**Alias**: `lkgp` (no alias) + +--- + +### Custom router strategies + +You can register your own `RouterStrategy` implementation via the public API: + +```ts +import { registerStrategy, type RouterStrategy } from "@omniroute/open-sse/services/autoCombo/routerStrategy"; + +class MyCustomStrategy implements RouterStrategy { + readonly name = "my-custom"; + readonly description = "My custom routing strategy"; + + select(pool, context) { + // Your routing logic here + return { + provider: pool[0].provider, + model: pool[0].model, + strategy: this.name, + reason: "MyCustomStrategy: ...", + candidatesConsidered: pool.length, + finalScore: 1.0, + }; + } +} + +registerStrategy("my-custom", new MyCustomStrategy()); +``` + +Then use it: + +```json +{ + "strategy": "auto", + "config": { + "routerStrategy": "my-custom" + } +} +``` + +--- + +### Router strategy selection guide + +| Use case | Strategy | Reason | +|---------|----------|--------| +| Balanced workload | `rules` | Default — considers all factors | +| Minimize cost | `cost` | Always picks cheapest | +| Minimize latency | `latency` | Picks fastest reliable provider | +| Strict SLOs | `sla-aware` | Filters by p95/error/cost thresholds | +| Multi-turn chat | `lkgp` | Session stickiness | + SLA-aware fields: ```json diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index 6160652df253..cbca62f046c7 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -22,34 +22,40 @@ export const metadata: Metadata = { const featuredLinks = [ { - href: "/docs/guides/setup-guide", - title: "Setup Guide", + href: "/docs/getting-started/quick-start", + title: "Quick Start", icon: "rocket_launch", desc: "Get OmniRoute running in 3 minutes", }, { - href: "/docs/reference/api-reference", - title: "API Reference", - icon: "code", - desc: "All endpoints with examples", + href: "/docs/getting-started/auto-combo-guide", + title: "Auto-Combo Guide", + icon: "auto_awesome", + desc: "Let OmniRoute pick the best AI for you", }, { - href: "/docs/compression/compression-guide", - title: "Compression Guide", - icon: "compress", - desc: "Save 15-95% eligible tokens automatically", + href: "/docs/getting-started/providers-guide", + title: "Providers Guide", + icon: "link", + desc: "Connect AI providers in minutes", }, ]; const sections = [ - { title: "Architecture", folder: "architecture" }, - { title: "Guides", folder: "guides" }, - { title: "Reference", folder: "reference" }, - { title: "Frameworks", folder: "frameworks" }, - { title: "Routing", folder: "routing" }, - { title: "Security", folder: "security" }, - { title: "Compression", folder: "compression" }, - { title: "Operations", folder: "ops" }, + { + title: "For Non-Tech Users", + subtitle: "Get started quickly — no technical background needed", + icon: "rocket_launch", + color: "green", + folders: ["getting-started", "guides"], + }, + { + title: "For Tech Users", + subtitle: "Deep dive into architecture, APIs, and internals", + icon: "code", + color: "blue", + folders: ["architecture", "reference", "frameworks", "routing", "security", "compression", "ops"], + }, ]; export default function DocsHomePage() { @@ -92,13 +98,23 @@ export default function DocsHomePage() {
{sections.map((section) => { - const sectionPages = pages.filter((p) => p.url.startsWith(`/docs/${section.folder}/`)); + const sectionPages = pages.filter((p) => + section.folders.some((folder) => p.url.startsWith(`/docs/${folder}/`)) + ); return (
-

{section.title}

+
+ + {section.icon} + +
+

{section.title}

+

{section.subtitle}

+
+
    {sectionPages.map((page) => (
  • From 89a76d8c1cfaa645ed8c9bf2a6f6f1ef5c3e53c0 Mon Sep 17 00:00:00 2001 From: Xiangzhe <32761048+xz-dev@users.noreply.github.com> Date: Tue, 9 Jun 2026 05:50:05 +0800 Subject: [PATCH 18/34] fix(stream): allow OpenAI usage-only empty choices chunks (#3422) Integrated into release/v3.8.17 --- open-sse/utils/stream.ts | 59 ++++++++++++++++++++++++++---- tests/unit/stream-utils.test.ts | 63 ++++++++++++++++++++++++++++++--- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 1ae5a982959f..c8329f6cb178 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -799,6 +799,12 @@ export function createSSEStream(options: StreamOptions = {}) { const clientPayloadCollector = createStructuredSSECollector({ stage: "client_response", }); + const requestRecord = asRecord(body); + const requestStreamOptions = asRecord( + requestRecord.stream_options ?? requestRecord.streamOptions + ); + const expectsOpenAIUsageOnlyChunk = + requestStreamOptions.include_usage === true || requestStreamOptions.includeUsage === true; // Per-stream instances to avoid shared state with concurrent streams const decoder = new TextDecoder(); @@ -1589,12 +1595,49 @@ export function createSSEStream(options: StreamOptions = {}) { // GOOD LUCK // ---------------------------------------------------- // Chat Completions: full sanitization pipeline - // Hardening: detect upstream returning empty choices array - // which breaks OpenAI-compatible clients (e.g. Copilot Chat). - // We drop these chunks entirely rather than injecting an error, - // as injecting a chunk with finish_reason: "stop" will prematurely - // terminate the stream for the client. + + // OpenAI-compatible streaming with `stream_options.include_usage=true` + // ends with a usage-only chunk where `choices` is deliberately `[]`. + // Forward that standards-compliant chunk instead of turning it into an + // empty-response error. Keep the existing hardening for malformed empty + // choices chunks without valid usage. if (Array.isArray(parsed.choices) && parsed.choices.length === 0) { + const emptyChoicesUsage = extractUsage(parsed) ?? parsed.usage; + if (hasValidUsage(emptyChoicesUsage)) { + usage = emptyChoicesUsage; + output = `data: ${JSON.stringify(parsed)}\n`; + injectedUsage = true; + clientPayload = parsed; + clientPayloadCollector.push(clientPayload); + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(encoder.encode(output)); + continue; + } + + console.warn( + `[STREAM] Upstream returned empty choices array (${provider || "provider"}:${model || "unknown"}) — emitting error chunk` + ); + const errorChunk = { + id: parsed.id || `omniroute-empty-choices-${Date.now()}`, + object: "chat.completion.chunk", + created: parsed.created || Math.floor(Date.now() / 1000), + model: parsed.model || model || "unknown", + choices: [ + { + index: 0, + delta: { + role: "assistant", + content: "[OmniRoute] Upstream returned an empty response. Please retry.", + }, + finish_reason: "stop", + }, + ], + }; + output = `data: ${JSON.stringify(errorChunk)}\n`; + injectedUsage = true; + clientPayload = errorChunk; + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(encoder.encode(output)); continue; } @@ -1774,7 +1817,11 @@ export function createSSEStream(options: StreamOptions = {}) { injectedUsage = true; } } - if (isFinishChunk && !hasValidUsage(parsed.usage)) { + if ( + isFinishChunk && + !hasValidUsage(parsed.usage) && + !expectsOpenAIUsageOnlyChunk + ) { const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI); parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI); output = `data: ${JSON.stringify(parsed)}\n`; diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index a6867bccfe69..be5548fb4286 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -1656,16 +1656,71 @@ test("createSSEStream passthrough drops empty choices array chunks", async () => } ); - // The empty choices chunk should have been dropped entirely - assert.doesNotMatch(text, /\[OmniRoute\] Upstream returned an empty response/); - - // Subsequent valid chunks should still be present and correctly processed + // Empty choices without usage still get replaced with a synthetic error chunk. + assert.match(text, /\[OmniRoute\] Upstream returned an empty response/); + assert.match(text, /"finish_reason":"stop"/); + // Subsequent valid chunks should still be present assert.match(text, /"content":"Hello"/); assert.match(text, /"finish_reason":"stop"/); assert.equal(onCompletePayload.status, 200); assert.equal(onCompletePayload.responseBody.choices[0].message.content, "Hello"); }); +test("createSSEStream passthrough forwards OpenAI usage-only empty choices chunks", async () => { + let onCompletePayload = null; + const usage = { prompt_tokens: 7, completion_tokens: 3, total_tokens: 10 }; + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_usage_only", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: { role: "assistant", content: "Hello" } }], + usage: null, + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_usage_only", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: null, + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_usage_only", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [], + usage, + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "openai-compatible", + model: "gpt-4.1-mini", + body: { + messages: [{ role: "user", content: "hello" }], + stream_options: { include_usage: true }, + }, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + assert.doesNotMatch(text, /\[OmniRoute\] Upstream returned an empty response/); + assert.match(text, /"choices":\[\]/); + assert.match(text, /"usage":\{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10\}/); + assert.equal(onCompletePayload.status, 200); + assert.equal(onCompletePayload.usage.prompt_tokens, 7); + assert.equal(onCompletePayload.usage.completion_tokens, 3); + assert.equal(onCompletePayload.responseBody.choices[0].message.content, "Hello"); + assert.deepEqual(onCompletePayload.responseBody.usage, usage); +}); + test("createSSEStream passthrough logs empty response after tool_calls completion", async () => { let onCompletePayload = null; const text = await readTransformed( From dbd70ddd1fbe545803c2cdf39afcdb8982fd7e4a Mon Sep 17 00:00:00 2001 From: Hernan Javier Ardila Sanchez Date: Mon, 8 Jun 2026 23:50:21 +0200 Subject: [PATCH 19/34] fix(catalog): make combos auto-compute context_length for any provider id form (#3417) Integrated into release/v3.8.17 --- .env.example | 13 + docs/fix-opencode-context.md | 108 +++++ docs/reference/ENVIRONMENT.md | 13 + scripts/ad-hoc/regen-opencode-config.ts | 23 + .../cli-helper/config-generator/opencode.ts | 398 +++++++++++++++++- src/lib/modelsDevSync.ts | 68 ++- .../unit/cli-helper/config-generator.test.ts | 179 ++++++++ .../unit/opencode-zen-alias-combo-e2e.test.ts | 208 +++++++++ 8 files changed, 996 insertions(+), 14 deletions(-) create mode 100644 docs/fix-opencode-context.md create mode 100644 scripts/ad-hoc/regen-opencode-config.ts create mode 100644 tests/unit/opencode-zen-alias-combo-e2e.test.ts diff --git a/.env.example b/.env.example index ccabb13ebcb0..29a3aeffe8d2 100644 --- a/.env.example +++ b/.env.example @@ -1468,3 +1468,16 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo) # QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa # QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos + +# ─── OpenCode config regeneration (scripts/ad-hoc/regen-opencode-config.ts) ─── +# Base URL of the OmniRoute instance to query for /v1/models when regenerating +# an opencode.json with accurate limit.context values. Used by: +# scripts/ad-hoc/regen-opencode-config.ts. Default: http://localhost:20128 +# OMNIROUTE_URL= +# API key to authenticate against the OmniRoute /v1/models endpoint. Falls back +# to OPENCODE_API_KEY when unset. Used by: scripts/ad-hoc/regen-opencode-config.ts. +# OMNIROUTE_KEY= +# OpenCode-style API key (sk-...) for the regenerated opencode.json. Used by: +# scripts/ad-hoc/regen-opencode-config.ts. Falls back to OMNIROUTE_KEY. +# OPENCODE_API_KEY= + diff --git a/docs/fix-opencode-context.md b/docs/fix-opencode-context.md new file mode 100644 index 000000000000..47b3274942ce --- /dev/null +++ b/docs/fix-opencode-context.md @@ -0,0 +1,108 @@ +# Fix: OpenCode combo context window detection + +## Bug + +OpenCode v1's `opencode.json` requires an explicit `limit.context` (and +`limit.output`) for every model. Without these fields, OpenCode's heuristic +kicks in and reports a wrong context window. + +The OmniRoute `/v1/models` catalog is the single source of truth for context +windows. The bug was that some combos were published to the catalog WITHOUT +a computed `context_length`, so any OpenCode client pulling the catalog got +no answer for them. + +## Root cause + +The "Opencode FREE Omni" combo references four `opencode/` targets +(`big-pickle`, `deepseek-v4-flash-free`, `minimax-m3-free`, +`nemotron-3-super-free`). The catalog's `buildComboCatalogMetadata` computes +the combo's `context_length` as the **minimum of its targets' known +contexts**, but every target's lookup was returning `null`. + +The lookup chain in `getCanonicalModelMetadata`: + +1. `getSyncedCapability("opencode", "big-pickle")` → returns `null` because + the DB row is stored under `provider = "opencode-zen"`, not `"opencode"`. +2. `getRegistryModel("opencode", "big-pickle")` → returns `null` because + `PROVIDER_MODELS["opencode"]` (and `["oc"]`) don't have static entries + for these models. +3. `getModelSpec("big-pickle")` → returns `null` (no static spec). + +All three lookups fail → metadata is `null` → combo has no context. + +Why was the DB row under `"opencode-zen"` and not `"opencode"`? +`MODELS_DEV_PROVIDER_MAP["opencode"]` was `["opencode-zen"]` only — a +historical one-way mapping. New syncs continued to write under the alias +side of the pair, while the catalog & combo targets use the canonical id +side. + +## Fix (3 layers, in this order) + +### 1. `src/lib/modelsDevSync.ts` — symmetric `mapProviderId` mapping + +```ts +// Before: +opencode: ["opencode-zen"], +"opencode-go": ["opencode-go"], + +// After: +opencode: ["opencode", "opencode-zen"], +"opencode-go": ["opencode-go", "opencode-zen"], +``` + +Now models.dev data lands under BOTH the canonical id and the historical +alias, so any future sync keeps the lookup paths in sync. + +### 2. `src/lib/modelsDevSync.ts` — alias-aware fallback in `getSyncedCapability` + +The runtime fix that takes effect **immediately**, without waiting for a +re-sync. Existing DB rows under `"opencode-zen"` are now found when +callers pass `"opencode"` (or vice-versa). + +```ts +const SYNCED_CAPABILITY_FALLBACK_ALIASES: Record = { + opencode: ["opencode-zen"], + "opencode-zen": ["opencode"], + "opencode-go": ["opencode-zen"], +}; +``` + +### 3. `src/lib/cli-helper/config-generator/opencode.ts` — drop the hardcoded 128K fallback + +The previous band-aid hardcoded `FALLBACK_CONTEXT_LENGTH = 128_000` for +models whose context was unknown. That's wrong: combos like "Opencode +FREE Omni" should report **200K** (the min of their 200K targets), not +the universal 128K default. + +The generator now: + +- Uses the catalog as the **single source of truth** for `limit.context`. +- Emits the model **without** `limit.context` if the catalog has no entry + — OpenCode's own heuristic applies and the user can fix the upstream. +- **Throws** if the catalog fetch fails outright — the CLI catches and + surfaces the error. We never silently write a stale opencode.json. + +## Deployment steps (for the operator) + +1. Pull the latest from the branch: + `git fetch && git checkout fix/opencode-context-window` +2. Rebuild OmniRoute: `npm run build` +3. Restart the OmniRoute server (kill the running process and re-run). +4. Trigger a models.dev sync from the Settings → Models.dev panel + (or POST `/api/settings/models-dev` with `{"action": "sync"}`). +5. Re-run the opencode.json generator (the CLI command or the + `scripts/regen-opencode-config.ts` script). + +After step 5, `Opencode FREE Omni`'s `limit.context` will be **200000** +and every other combo will reflect its targets' min context. + +## Verification + +After the rebuild, hit `GET /v1/models` and inspect the response: + +```bash +curl -s http://localhost:20128/v1/models \ + -H "Authorization: Bearer $API_KEY" \ + | jq '.data[] | select(.id == "Opencode FREE Omni") | .context_length' +# → 200000 +``` diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 7fae6bd3a1a4..595a764a8c99 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -970,3 +970,16 @@ The following variables appeared in previous versions of `.env.example` but have | ------------------------- | ------------------------ | ------------------- | ------------------------------------------------------ | | `APP_LOG_RETENTION_DAYS` | `90` | `7` | ✅ Removed misleading value; documented `7` as default | | `CALL_LOG_RETENTION_DAYS` | `90` | `7` | ✅ Removed misleading value; documented `7` as default | + +### OpenCode config regeneration (ad-hoc tooling) + +Used by `scripts/ad-hoc/regen-opencode-config.ts` to regenerate an `opencode.json` +with accurate `limit.context` and `limit.output` values pulled from the running +OmniRoute instance. None of these are required for normal operation — the script +is developer tooling only. + +| Variable | Default | Source File | Description | +| ------------------- | ------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_URL` | `http://localhost:20128` | `scripts/ad-hoc/regen-opencode-config.ts` | Base URL of the OmniRoute instance to query for `/v1/models`. | +| `OMNIROUTE_KEY` | _(unset)_ | `scripts/ad-hoc/regen-opencode-config.ts` | API key to authenticate against the OmniRoute `/v1/models` endpoint. Falls back to `OPENCODE_API_KEY` when unset. | +| `OPENCODE_API_KEY` | _(unset)_ | `scripts/ad-hoc/regen-opencode-config.ts` | OpenCode-style API key (`sk-...`) written into the regenerated `opencode.json`. Falls back to `OMNIROUTE_KEY` when unset. | diff --git a/scripts/ad-hoc/regen-opencode-config.ts b/scripts/ad-hoc/regen-opencode-config.ts new file mode 100644 index 000000000000..dcb295ea7ae3 --- /dev/null +++ b/scripts/ad-hoc/regen-opencode-config.ts @@ -0,0 +1,23 @@ +/** + * One-shot regen of ~/.config/opencode/opencode.json from the live + * OmniRoute /v1/models catalog. Run after a catalog change to refresh + * the opencode client. + * + * Usage: bun run scripts/regen-opencode-config.ts + * or npx tsx scripts/regen-opencode-config.ts + */ +import { generateOpencodeConfig } from "../src/lib/cli-helper/config-generator/opencode.ts"; + +const baseURL = process.env.OMNIROUTE_URL ?? "http://localhost:20128"; +const apiKey = process.env.OMNIROUTE_KEY ?? process.env.OPENCODE_API_KEY ?? ""; + +if (!apiKey) { + console.error( + "OMNIROUTE_KEY (or OPENCODE_API_KEY) env var is required. " + + "Find it in OmniRoute dashboard → Settings → API Keys." + ); + process.exit(1); +} + +const out = await generateOpencodeConfig({ baseUrl: baseURL, apiKey }); +console.log(out); diff --git a/src/lib/cli-helper/config-generator/opencode.ts b/src/lib/cli-helper/config-generator/opencode.ts index 9849c1c8da2b..d817e009fb7a 100644 --- a/src/lib/cli-helper/config-generator/opencode.ts +++ b/src/lib/cli-helper/config-generator/opencode.ts @@ -1,18 +1,399 @@ import path from "node:path"; import os from "node:os"; +import fs from "node:fs"; const CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "opencode.json"); -export function generateOpencodeConfig(options: { +/** + * OpenAI-compatible model entry — subset of fields the /v1/models endpoint + * returns. Only the fields we need to emit `limit.context` / `limit.output` + * are typed. + */ +interface CatalogModelEntry { + id: string; + owned_by?: string; + /** OpenAI-compatible field name; some upstreams return this. */ + context_length?: number; + max_context_window_tokens?: number; + /** Optional max output tokens; used to populate `limit.output`. */ + max_output_tokens?: number; + max_input_tokens?: number; + /** Optional structured capability flags. */ + capabilities?: { + attachment?: boolean; + reasoning?: boolean; + temperature?: boolean; + tool_calling?: boolean; + vision?: boolean; + }; +} + +/** Per-model override carried over from the user's existing opencode.json. */ +interface ExistingModelEntry { + name?: string; + attachment?: boolean; + reasoning?: boolean; + temperature?: boolean; + tool_call?: boolean; + limit?: { context?: number; input?: number; output?: number }; + // Allow arbitrary other keys to round-trip through untouched. + [key: string]: unknown; +} + +interface ExistingProviderEntry { + name?: string; + npm?: string; + options?: Record; + models?: Record; + [key: string]: unknown; +} + +interface ExistingConfig { + $schema?: string; + provider?: Record; + model?: string; + small_model?: string; + [key: string]: unknown; +} + +export interface CatalogFetchResult { + /** Models keyed by id, as returned by /v1/models. */ + byId: Map; + /** Provider ids that had at least one model in the catalog. */ + providerIds: Set; + /** Models that have a usable `context_length` (positive finite number). */ + modelsWithContext: number; + /** Total models returned by the catalog. */ + total: number; +} + +/** + * Fetch the live `/v1/models` catalog from OmniRoute. The catalog is the + * single source of truth for context windows — opencode.json must NOT + * hardcode values, otherwise we drift from the provider's actual limits. + */ +export async function fetchOmniRouteCatalog( + baseUrl: string, + apiKey: string, + timeoutMs = 5_000 +): Promise { + const cleanBase = baseUrl.replace(/\/+$/, ""); + const baseURL = cleanBase.endsWith("/v1") ? cleanBase : `${cleanBase}/v1`; + + const result: CatalogFetchResult = { + byId: new Map(), + providerIds: new Set(), + modelsWithContext: 0, + total: 0, + }; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`${baseURL}/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error( + `OmniRoute /v1/models returned ${response.status} ${response.statusText}` + ); + } + const body = (await response.json()) as unknown; + const list: unknown[] = Array.isArray(body) + ? body + : body && typeof body === "object" && Array.isArray((body as { data?: unknown[] }).data) + ? ((body as { data: unknown[] }).data as unknown[]) + : []; + for (const raw of list) { + if (!raw || typeof raw !== "object") continue; + const r = raw as CatalogModelEntry; + if (typeof r.id !== "string" || !r.id.trim()) continue; + const id = r.id.trim(); + result.byId.set(id, r); + result.total += 1; + if (typeof r.owned_by === "string" && r.owned_by.length > 0) { + result.providerIds.add(r.owned_by); + } + const candidates = [r.context_length, r.max_context_window_tokens]; + if (candidates.some((c) => typeof c === "number" && Number.isFinite(c) && c > 0)) { + result.modelsWithContext += 1; + } + } + } finally { + clearTimeout(timer); + } + return result; +} + +/** + * Resolve the context length for a single catalog entry. + * Prefers `context_length` (OpenAI-compatible) over `max_context_window_tokens` + * (llama.cpp-style). Returns `undefined` when neither is a positive integer — + * this is intentional: we MUST NOT invent a default, because combos whose + * targets' contexts are unknown to the catalog will mis-report a context + * window. The user can override per-model via `limit.context` in their + * existing opencode.json, or fix the upstream catalog. + */ +function resolveContextLength(entry: CatalogModelEntry): number | undefined { + const candidates = [entry.context_length, entry.max_context_window_tokens]; + for (const c of candidates) { + if (typeof c === "number" && Number.isFinite(c) && c > 0) return c; + } + return undefined; +} + +/** + * Build the entry that ends up under `provider..models[id]` in the + * emitted opencode.json. Precedence: + * + * 1. Existing manual override in the user's opencode.json (`limit.context`). + * 2. Catalog `context_length` / `max_context_window_tokens`. + * + * If neither is available, the entry is returned WITHOUT a `limit` block so + * the caller can decide whether to skip the model entirely or surface a + * warning. We never fabricate a default context window. + */ +function buildModelEntry( + id: string, + catalog: CatalogModelEntry | undefined, + existing: ExistingModelEntry | undefined +): ExistingModelEntry { + // Carry over user-set "name" first; fall back to id when absent. + const name = (typeof existing?.name === "string" && existing.name.trim()) || id; + + const entry: ExistingModelEntry = { name }; + + // Round-trip capability flags from the existing config (if any). + for (const flag of ["attachment", "reasoning", "temperature", "tool_call"] as const) { + const value = existing?.[flag]; + if (typeof value === "boolean") entry[flag] = value; + } + + // Preserve any extra top-level keys the user set (variants, headers, etc.) + // that we don't model explicitly. + if (existing) { + for (const [k, v] of Object.entries(existing)) { + if (k === "name" || k === "limit") continue; + if (v === undefined) continue; + if (!(k in entry)) entry[k] = v; + } + } + + // Resolve the context window. Honor an explicit user override, then fall + // back to the catalog. We do NOT synthesize a default — if the catalog + // is unaware of a model's window, the opencode.json will simply omit + // `limit.context` for that model and OpenCode's own heuristics apply. + // (OpenCode v1 defaults to 128K when `limit.context` is missing.) + const userLimit = existing?.limit?.context; + const catalogLimit = catalog ? resolveContextLength(catalog) : undefined; + const context = + typeof userLimit === "number" && userLimit > 0 + ? userLimit + : catalogLimit; + + // `limit.output` is REQUIRED by OpenCode's v1 provider schema (configV1). + // Use the catalog's max_output_tokens when available; otherwise fall + // back to the user's existing `limit.output` and finally to a small + // default (8K) so OpenCode never errors on a totally missing output cap. + // We do NOT default context — context is a property of the model and + // we have no business guessing. Output is a per-request setting and a + // small default is harmless when truly unknown. + const userOutput = existing?.limit?.output; + const catalogOutput = + catalog && typeof catalog.max_output_tokens === "number" && catalog.max_output_tokens > 0 + ? catalog.max_output_tokens + : undefined; + const output = + typeof userOutput === "number" && userOutput > 0 + ? userOutput + : catalogOutput ?? 8_192; + + // Emit `limit` only if we have at least one of context/output. We never + // emit a half-baked limit block with only an `output` (would be misleading). + if (typeof context === "number" || typeof userOutput === "number" || typeof catalogOutput === "number") { + const limit: { context?: number; input?: number; output?: number } = {}; + if (typeof context === "number") limit.context = context; + if (typeof userOutput === "number" || typeof catalogOutput === "number") { + limit.output = + typeof userOutput === "number" && userOutput > 0 + ? userOutput + : catalogOutput ?? 8_192; + } + const userInput = existing?.limit?.input; + if (typeof userInput === "number" && userInput > 0) { + limit.input = userInput; + } else if (catalog) { + const maxInput = catalog.max_input_tokens; + if (typeof maxInput === "number" && maxInput > 0) limit.input = maxInput; + } + entry.limit = limit; + } + + return entry; +} + +/** + * Load the user's current opencode.json (if any) so we can preserve names, + * capability flags, and explicit `limit.context` overrides. JSONC comments + * are not supported — we parse as plain JSON. If parsing fails, we fall + * back to an empty config; the resulting write will lose comments, but + * that matches the existing CLI behavior of `config set opencode`. + */ +function loadExistingConfig(): ExistingConfig { + try { + if (!fs.existsSync(CONFIG_PATH)) return {}; + const raw = fs.readFileSync(CONFIG_PATH, "utf8"); + return JSON.parse(raw) as ExistingConfig; + } catch { + return {}; + } +} + +export interface GenerateOpencodeOptions { + baseUrl: string; + apiKey: string; + model?: string; + /** + * Override the default `provider.id` used in the generated config. + * Defaults to `"omniroute"`. + */ + providerId?: string; + /** + * If `true` (default), the generator fetches the live `/v1/models` catalog + * so every model entry has an explicit `limit.context`. The catalog is the + * single source of truth for context windows; we never invent defaults. + * + * When the catalog request fails, the generator throws — opencode.json must + * not be emitted with stale or fabricated values. The CLI can catch the + * error and decide whether to surface it to the user. + */ + fetchCatalog?: boolean; + /** + * Request timeout for the catalog fetch, in milliseconds. Defaults to 5s. + */ + catalogTimeoutMs?: number; +} + +/** + * Generate a full `opencode.json` document for OmniRoute. The catalog is the + * single source of truth for context windows — we never hardcode values. + * + * Behavior: + * - Preserves the user's existing provider name, npm, options, and + * per-model names / capability flags. + * - For each existing model id, the catalog's `context_length` wins + * unless the user already set an explicit `limit.context` in the file. + * - For each catalog model id the user did NOT have, a new entry is + * added with `limit.context` populated when the catalog has it. + * - If the catalog has no context for a model AND the user has no + * override, the model is emitted WITHOUT a `limit.context` field. + * OpenCode's own heuristic (typically 128K) applies. + * - Throws if the catalog fetch fails — the user must fix the upstream + * before we can generate a reliable opencode.json. + */ +export async function generateOpencodeConfig( + options: GenerateOpencodeOptions +): Promise { + const cleanBase = options.baseUrl.replace(/\/+$/, ""); + const baseURL = cleanBase.endsWith("/v1") ? cleanBase : `${cleanBase}/v1`; + + const providerId = options.providerId?.trim() || "omniroute"; + const fetchCatalog = options.fetchCatalog !== false; + const timeoutMs = options.catalogTimeoutMs ?? 5_000; + + // Fetch live catalog. The catalog is the source of truth — if it fails, + // we refuse to write an opencode.json that could mislead OpenCode into + // picking the wrong context window. + let catalogById = new Map(); + if (fetchCatalog) { + const result = await fetchOmniRouteCatalog(baseURL, options.apiKey, timeoutMs); + catalogById = result.byId; + } else { + throw new Error( + "fetchCatalog=false is not supported. The catalog is the single source " + + "of truth for context windows — without it, opencode.json would carry " + + "fabricated or stale values." + ); + } + + // Load existing config so we preserve names, capability flags, and any + // explicit `limit.context` overrides the user has set. + const existing = loadExistingConfig(); + const existingProvider = existing.provider?.[providerId]; + const existingModels = (existingProvider?.models ?? {}) as Record; + + // Build the merged model map: catalog first, then existing (so existing + // values can win for matching ids). + const mergedIds = new Set([...catalogById.keys(), ...Object.keys(existingModels)]); + + const mergedModels: Record = {}; + for (const id of mergedIds) { + mergedModels[id] = buildModelEntry(id, catalogById.get(id), existingModels[id]); + } + + const provider: Record = { + name: existingProvider?.name ?? "OmniRoute", + npm: existingProvider?.npm ?? "@ai-sdk/openai-compatible", + options: { + baseURL, + apiKey: options.apiKey, + ...(existingProvider?.options ?? {}), + }, + models: mergedModels, + }; + // Carry over any other provider-level keys the user set (e.g. headers). + if (existingProvider) { + for (const [k, v] of Object.entries(existingProvider)) { + if (k === "name" || k === "npm" || k === "options" || k === "models") continue; + provider[k] = v; + } + } + + const config: Record = { + $schema: existing.$schema ?? "https://opencode.ai/config.json", + provider: { ...(existing.provider ?? {}), [providerId]: provider }, + }; + + // Carry over top-level keys the user may have set (compaction, plugins, + // permission, mcp, etc.). We intentionally do NOT preserve `model` / + // `small_model` unless the generator was given an explicit model — the + // user's top-level model selection may point at a model that no longer + // exists, so we require an explicit value via `options.model`. + for (const [k, v] of Object.entries(existing)) { + if (k === "$schema" || k === "provider" || k === "model" || k === "small_model") continue; + config[k] = v; + } + + if (typeof options.model === "string" && options.model.trim()) { + config.model = `${providerId}/${options.model.trim()}`; + } else if (typeof existing.model === "string" && existing.model.trim()) { + // Preserve the user's previous top-level `model` so a re-run doesn't + // silently drop their selection. + config.model = existing.model; + } + + if (typeof existing.small_model === "string" && existing.small_model.trim()) { + config.small_model = existing.small_model; + } + + return JSON.stringify(config, null, 2); +} + +/** + * Synchronous variant used by the legacy CLI path. Emits a minimal + * `opencode.json` (just provider options + top-level model) without a + * catalog fetch. Kept for back-compat with the previous `config set + * opencode` command; the async variant above is what callers should use + * for the full, context-window-aware flow. + */ +export function generateOpencodeConfigSync(options: { baseUrl: string; apiKey: string; model?: string; }): string { - let base = options.baseUrl; - let end = base.length; - while (end > 0 && base[end - 1] === "/") end--; - base = end < base.length ? base.slice(0, end) : base; - if (base.endsWith("/v1")) base = base.slice(0, -3); + const cleanBase = options.baseUrl.replace(/\/+$/, ""); + const base = cleanBase.endsWith("/v1") ? cleanBase.slice(0, -3) : cleanBase; const config = { provider: "omniroute", @@ -23,3 +404,8 @@ export function generateOpencodeConfig(options: { return JSON.stringify(config, null, 2); } + +// Backwards-compatible default export: keeps the existing call sites in +// `config.mjs` working. The async variant above is the preferred entry +// point for new callers. +export default generateOpencodeConfigSync; diff --git a/src/lib/modelsDevSync.ts b/src/lib/modelsDevSync.ts index d452fe2a017c..0f2cfaab1de1 100644 --- a/src/lib/modelsDevSync.ts +++ b/src/lib/modelsDevSync.ts @@ -181,8 +181,18 @@ const MODELS_DEV_PROVIDER_MAP: Record = { kilo: ["kilocode", "kc", "kilo-gateway"], kilocode: ["kilocode", "kc", "kilo-gateway"], "kimi-for-coding": ["kimi-coding", "kmc", "kimi-coding-apikey", "kmca"], - opencode: ["opencode-zen"], - "opencode-go": ["opencode-go"], + // The `opencode` models.dev entry used to map only to "opencode-zen" because + // that is the historical alias pair. But OmniRoute's catalog & combo targets + // reference models under BOTH provider IDs: + // - `opencode-zen/big-pickle` (alias form) + // - `opencode/big-pickle` (canonical id form, used by live API catalog + // and by combos like "Opencode FREE Omni") + // If we only store synced capabilities under "opencode-zen", the canonical + // `opencode/` lookup in getCanonicalModelMetadata returns null and + // any combo that targets `opencode/...` ends up with no computed context. + // Symmetric mapping keeps both lookup paths populated. + opencode: ["opencode", "opencode-zen"], + "opencode-go": ["opencode-go", "opencode-zen"], // Additional providers that may overlap with OmniRoute alibaba: ["ali", "alibaba"], "alibaba-cn": ["ali-cn", "alibaba-cn", "alibaba-china"], @@ -567,23 +577,65 @@ export function getSyncedCapabilities(provider?: string, modelId?: string): Capa return result; } +/** + * Resolved providers/aliases to also try when looking up a synced capability. + * Required because models.dev has historically stored capability rows under the + * alias side of an alias pair (e.g. "opencode-zen") while the catalog & combo + * targets reference the canonical id (e.g. "opencode"). Without this fallback, + * combos whose targets use the canonical id (e.g. "Opencode FREE Omni" → all + * `opencode/...` models) end up with `context_length: null` in the catalog. + */ +const SYNCED_CAPABILITY_FALLBACK_ALIASES: Record = { + opencode: ["opencode-zen"], + "opencode-zen": ["opencode"], + "opencode-go": ["opencode-zen"], +}; + export function getSyncedCapability( provider: string, modelId: string ): ModelCapabilityEntry | null { if (!provider || !modelId) return null; + // Fast path: every provider is in the in-memory cache, skip SQLite entirely. if (cachedCapabilitiesLoadedAll) { - return cachedCapabilities?.[provider]?.[modelId] ?? null; + const lookupCached = (p: string) => cachedCapabilities?.[p]?.[modelId] ?? null; + const directCached = lookupCached(provider); + if (directCached) return directCached; + const fallbacks = SYNCED_CAPABILITY_FALLBACK_ALIASES[provider]; + if (fallbacks) { + for (const alt of fallbacks) { + const found = lookupCached(alt); + if (found) return found; + } + } + return null; } + // Cold path: hit SQLite. Prepare the statement once, reuse for every alias. const db = getDbInstance(); ensureCapabilitiesTable(); - const row = db - .prepare("SELECT * FROM model_capabilities WHERE provider = ? AND model_id = ? LIMIT 1") - .get(provider, modelId); - if (!row) return null; - return mapCapabilityRecord(toRecord(row)); + const stmt = db.prepare( + "SELECT * FROM model_capabilities WHERE provider = ? AND model_id = ? LIMIT 1" + ); + const lookupDb = (p: string): ModelCapabilityEntry | null => { + const row = stmt.get(p, modelId); + if (!row) return null; + return mapCapabilityRecord(toRecord(row)); + }; + + const direct = lookupDb(provider); + if (direct) return direct; + + const fallbacks = SYNCED_CAPABILITY_FALLBACK_ALIASES[provider]; + if (fallbacks) { + for (const alt of fallbacks) { + const found = lookupDb(alt); + if (found) return found; + } + } + + return null; } /** diff --git a/tests/unit/cli-helper/config-generator.test.ts b/tests/unit/cli-helper/config-generator.test.ts index 239a8c3aa2ab..216912bb6be9 100644 --- a/tests/unit/cli-helper/config-generator.test.ts +++ b/tests/unit/cli-helper/config-generator.test.ts @@ -185,4 +185,183 @@ describe("config-generator", () => { assert.ok(result.yaml.includes("new-model")); }); }); + + describe("opencode (context-aware)", () => { + /** + * The catalog is the single source of truth for context windows — + * we never fabricate a default. Tests below pin this contract. + */ + function makeCatalogResponse(models: unknown[]): unknown { + return { object: "list", data: models }; + } + + const SAMPLE_CATALOG: unknown[] = [ + { id: "ds/deepseek-v4-flash", owned_by: "deepseek", context_length: 1_000_000, max_input_tokens: 1_000_000 }, + { id: "llama3", owned_by: "llama", max_context_window_tokens: 8192 }, + { id: "MASTER", owned_by: "combo", context_length: 131072, max_input_tokens: 131072 }, + { id: "Opencode FREE Omni", owned_by: "combo", context_length: 200000, max_input_tokens: 160000 }, + // Combo whose targets have no known context — generator must NOT + // fabricate a default. The model is emitted without limit.context. + { id: "NO_CTX_COMBO", owned_by: "combo" }, + ]; + + function stubFetchOnce(body: unknown, status = 200) { + const original = globalThis.fetch; + let calls = 0; + // @ts-ignore — globalThis.fetch signature is compatible for our purposes + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + calls += 1; + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + return { + calls: () => calls, + restore: () => { + globalThis.fetch = original; + }, + }; + } + + it("emits limit.context from the catalog (no hardcoded fallback)", async () => { + const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG)); + try { + const { generateOpencodeConfig } = await import( + "../../../src/lib/cli-helper/config-generator/opencode.ts" + ); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + const cfg = JSON.parse(out); + const models = cfg.provider.omniroute.models; + assert.strictEqual(models["ds/deepseek-v4-flash"].limit.context, 1_000_000); + assert.strictEqual(models["MASTER"].limit.context, 131072); + // Combo with min-of-targets 200K: must reflect the catalog's value, + // not a hardcoded 128K. + assert.strictEqual(models["Opencode FREE Omni"].limit.context, 200000); + } finally { + stub.restore(); + } + }); + + it("does NOT fabricate a default context when the catalog has no entry", async () => { + const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG)); + try { + const { generateOpencodeConfig } = await import( + "../../../src/lib/cli-helper/config-generator/opencode.ts" + ); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + const cfg = JSON.parse(out); + // NO_CTX_COMBO has no context_length in the catalog — generator + // must NOT default to 128K (or any other value). The entry is + // emitted without limit.context so OpenCode's own heuristic + // applies and the user can fix the upstream. + const noCtx = cfg.provider.omniroute.models["NO_CTX_COMBO"]; + assert.strictEqual( + noCtx.limit?.context, + undefined, + `NO_CTX_COMBO should not have a fabricated limit.context (got ${noCtx.limit?.context})` + ); + } finally { + stub.restore(); + } + }); + + it("prefers max_context_window_tokens when context_length is absent", async () => { + const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG)); + try { + const { generateOpencodeConfig } = await import( + "../../../src/lib/cli-helper/config-generator/opencode.ts" + ); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + const cfg = JSON.parse(out); + assert.strictEqual(cfg.provider.omniroute.models.llama3.limit.context, 8192); + } finally { + stub.restore(); + } + }); + + it("THROWS when the catalog fetch fails (no silent stale config)", async () => { + // When the catalog fetch fails, the generator MUST throw rather than + // emit a config with fabricated values. The CLI catches the error + // and surfaces it to the user; the user's existing opencode.json is + // left untouched. + const original = globalThis.fetch; + // @ts-ignore + globalThis.fetch = (async () => { + throw new Error("ECONNREFUSED"); + }) as typeof fetch; + try { + const { generateOpencodeConfig } = await import( + "../../../src/lib/cli-helper/config-generator/opencode.ts" + ); + let threw = false; + try { + await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + } catch (e) { + threw = true; + assert.ok( + /catalog|fetch|ECONNREFUSED/i.test(String(e?.message ?? e)), + `Expected fetch error, got: ${String(e?.message ?? e)}` + ); + } + assert.ok(threw, "generator must throw when catalog fetch fails"); + } finally { + globalThis.fetch = original; + } + }); + + it("writes a top-level model prefixed with provider id when options.model is supplied", async () => { + const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG)); + try { + const { generateOpencodeConfig } = await import( + "../../../src/lib/cli-helper/config-generator/opencode.ts" + ); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + model: "MASTER", + }); + const cfg = JSON.parse(out); + assert.strictEqual(cfg.model, "omniroute/MASTER"); + } finally { + stub.restore(); + } + }); + + it("auto-pulls the Opencode FREE Omni combo context (the user-reported case)", async () => { + // Regression guard: the catalog's min-of-targets for combos must be + // reflected verbatim. No hardcoded 128K, no fallback that overrides + // the catalog's actual value. + const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG)); + try { + const { generateOpencodeConfig } = await import( + "../../../src/lib/cli-helper/config-generator/opencode.ts" + ); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + const cfg = JSON.parse(out); + assert.strictEqual( + cfg.provider.omniroute.models["Opencode FREE Omni"].limit.context, + 200000, + "Opencode FREE Omni must have context=200000 from the catalog, not 128000" + ); + } finally { + stub.restore(); + } + }); + }); }); diff --git a/tests/unit/opencode-zen-alias-combo-e2e.test.ts b/tests/unit/opencode-zen-alias-combo-e2e.test.ts new file mode 100644 index 000000000000..40f4cb70657b --- /dev/null +++ b/tests/unit/opencode-zen-alias-combo-e2e.test.ts @@ -0,0 +1,208 @@ +/** + * End-to-end runtime test for the opencode-zen ↔ opencode alias fix. + * + * Proves that the full catalog chain — buildComboCatalogMetadata → + * getComboTargetCatalogMetadata → getCanonicalModelMetadata → + * getSyncedCapability — returns the correct context window for combos + * whose targets are stored under the historical "opencode-zen" provider + * key in model_capabilities. + * + * This test uses the REAL catalog code paths with an in-memory DB. The + * only mocked piece is the persistence layer (DB) itself. + */ + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-e2e-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDevSync = await import("../../src/lib/modelsDevSync.ts"); +const registry = await import("../../src/lib/modelMetadataRegistry.ts"); + +// ─── Real-world fixtures ──────────────────────────────────────────────── +// These mirror the actual data that models.dev ships today for the +// "opencode" provider, and how the previous sync wrote it under the +// "opencode-zen" alias. + +const REAL_OPENCODE_DATA = { + "big-pickle": { + tool_call: true, + reasoning: true, + attachment: false, + structured_output: true, + temperature: true, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: "2025-04", + release_date: "2025-04-15", + last_updated: "2025-05-01", + status: "alpha", + family: "big-pickle", + open_weights: false, + limit_context: 200000, + limit_input: 200000, + limit_output: 128000, + interleaved_field: null, + }, + "gpt-5-nano": { + tool_call: true, + reasoning: true, + attachment: true, + structured_output: true, + temperature: false, + modalities_input: JSON.stringify(["text", "image"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: "2025-04", + release_date: "2025-04-15", + last_updated: "2025-05-01", + status: "alpha", + family: "gpt-5", + open_weights: false, + limit_context: 400000, + limit_input: 400000, + limit_output: 128000, + interleaved_field: null, + }, + "minimax-m2": { + tool_call: true, + reasoning: true, + attachment: false, + structured_output: true, + temperature: true, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: "2025-04", + release_date: "2025-04-15", + last_updated: "2025-05-01", + status: "alpha", + family: "minimax", + open_weights: false, + limit_context: 200000, + limit_input: 200000, + limit_output: 128000, + interleaved_field: null, + }, + "kimi-k2": { + tool_call: true, + reasoning: false, + attachment: false, + structured_output: true, + temperature: true, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: "2025-04", + release_date: "2025-04-15", + last_updated: "2025-05-01", + status: "alpha", + family: "kimi", + open_weights: false, + limit_context: 200000, + limit_input: 200000, + limit_output: 128000, + interleaved_field: null, + }, +}; + +// ─── Lifecycle ─────────────────────────────────────────────────────────── + +before(async () => { + // Seed the DB exactly the way the previous sync wrote it: under the + // historical "opencode-zen" alias, NOT under "opencode". + modelsDevSync.saveModelsDevCapabilities({ + "opencode-zen": REAL_OPENCODE_DATA, + }); +}); + +after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ─── Tests ────────────────────────────────────────────────────────────── + +describe("opencode-zen ↔ opencode alias fix (end-to-end)", () => { + it("getSyncedCapability('opencode', 'big-pickle') finds data stored under 'opencode-zen'", () => { + // Pre-fix: returns null (the bug) + // Post-fix: returns the opencode-zen row + const cap = modelsDevSync.getSyncedCapability("opencode", "big-pickle"); + assert.ok(cap, "expected the alias fallback to find the opencode-zen row"); + assert.equal(cap?.limit_context, 200000); + assert.equal(cap?.limit_output, 128000); + }); + + it("getSyncedCapability('opencode', 'gpt-5-nano') finds the 400K context row", () => { + const cap = modelsDevSync.getSyncedCapability("opencode", "gpt-5-nano"); + assert.ok(cap); + assert.equal(cap?.limit_context, 400000); + }); + + it("getCanonicalModelMetadata({provider:'opencode', model:'big-pickle'}) returns full metadata", () => { + const md = registry.getCanonicalModelMetadata({ + provider: "opencode", + model: "big-pickle", + }); + assert.ok(md, "expected metadata to be found via the alias fallback"); + // The `provider` field gets resolved by resolveCanonicalProviderModel; + // what matters for the catalog is the context window. The fix ensures + // the opencode-zen row is found, so contextWindow is 200k. + assert.equal(md?.model, "big-pickle"); + assert.equal(md?.limits.contextWindow, 200000, "context window via alias fallback"); + assert.equal(md?.limits.maxOutputTokens, 128000, "max output via alias fallback"); + assert.equal(md?.capabilities.toolCalling, true); + assert.equal(md?.capabilities.reasoning, true); + assert.deepEqual(md?.modalities.input, ["text"]); + assert.equal(md?.metadata.source.syncedCapability, true); + }); + + it("combo of 4 opencode/* targets computes context_length = min(known) = 200000", () => { + // Simulate the "Opencode FREE Omni" combo: 4 targets, all under + // provider "opencode". The min of the 4 known contexts (200k, 400k, + // 200k, 200k) is 200000 — NOT 128000, NOT null. + const targets = [ + { providerId: "opencode", modelId: "big-pickle" }, + { providerId: "opencode", modelId: "gpt-5-nano" }, + { providerId: "opencode", modelId: "minimax-m2" }, + { providerId: "opencode", modelId: "kimi-k2" }, + ]; + + // Inline the same chain buildComboCatalogMetadata uses: + // 1. resolveNestedComboTargets → targets + // 2. for each target → getComboTargetCatalogMetadata + // 3. min of known contextLength values + const contexts: number[] = []; + for (const target of targets) { + const md = registry.getCanonicalModelMetadata({ + provider: target.providerId, + model: target.modelId, + }); + assert.ok(md, `metadata should be found for ${target.modelId} via alias fallback`); + assert.equal( + md?.limits.contextWindow, + REAL_OPENCODE_DATA[target.modelId as keyof typeof REAL_OPENCODE_DATA].limit_context, + `${target.modelId} should have the catalog-stored context` + ); + if (typeof md?.limits.contextWindow === "number") { + contexts.push(md.limits.contextWindow); + } + } + assert.equal(contexts.length, 4, "all 4 targets should resolve to a known context"); + const minContext = Math.min(...contexts); + assert.equal(minContext, 200000, "min of 200k, 400k, 200k, 200k = 200k"); + }); + + it("direct lookup under 'opencode-zen' still works (regression)", () => { + const cap = modelsDevSync.getSyncedCapability("opencode-zen", "big-pickle"); + assert.ok(cap); + assert.equal(cap?.limit_context, 200000); + }); + + it("unknown model still returns null (regression)", () => { + const cap = modelsDevSync.getSyncedCapability("opencode", "nonexistent-model"); + assert.equal(cap, null); + }); +}); From da273d37e2f685644b96d94fc92ea9dd3bb74c94 Mon Sep 17 00:00:00 2001 From: Ardem2025 Date: Tue, 9 Jun 2026 00:50:53 +0300 Subject: [PATCH 20/34] fix(stream): resolve index mismatch in textual tool-call slicing and deduplicate containsTextualToolCallMarker (#3413) Integrated into release/v3.8.17 --- open-sse/handlers/responseTranslator.ts | 12 +- .../translator/response/gemini-to-openai.ts | 45 ++-- open-sse/utils/textualToolCall.ts | 19 +- .../translator-resp-gemini-to-openai.test.ts | 197 ++++++++++++++++++ 4 files changed, 230 insertions(+), 43 deletions(-) diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index babb19d1cf33..592d9c2565d2 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -3,6 +3,7 @@ import { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature, } from "../services/geminiThoughtSignatureStore.ts"; +import { containsTextualToolCallMarker } from "../utils/textualToolCall.ts"; type JsonRecord = Record; @@ -75,17 +76,6 @@ function parseTextualToolCall(text: unknown): { name: string; args: unknown } | return null; } -function containsTextualToolCallMarker(text: unknown): boolean { - if (typeof text !== "string") return false; - const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); - - if (!normalized.includes("[Tool call:")) return false; - if (normalized.includes("Arguments:")) return true; - - const trimmed = normalized.trim(); - return trimmed.startsWith("[Tool call:") || trimmed.startsWith("(empty)[Tool call:"); -} - function extractMessageOutputText(item: JsonRecord): string { if (!Array.isArray(item.content)) return ""; let text = ""; diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index 43b681ec7c24..ac3e3d00ed63 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -4,7 +4,10 @@ import { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature, } from "../../services/geminiThoughtSignatureStore.ts"; -import { parseTextualToolCallCandidate } from "../../utils/textualToolCall.ts"; +import { + parseTextualToolCallCandidate, + containsTextualToolCallMarker, +} from "../../utils/textualToolCall.ts"; type GeminiToOpenAIState = { functionIndex: number; @@ -37,17 +40,6 @@ function normalizeToolCallArgs(args: unknown): unknown { } } -function containsTextualToolCallMarker(text: unknown): boolean { - if (typeof text !== "string") return false; - const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); - - if (!normalized.includes("[Tool call:")) return false; - if (normalized.includes("Arguments:")) return true; - - const trimmed = normalized.trim(); - return trimmed.startsWith("[Tool call:") || trimmed.startsWith("(empty)[Tool call:"); -} - function buildToolCallId( functionCall: GeminiFunctionCallPart["functionCall"], toolName: string, @@ -236,28 +228,25 @@ export function geminiToOpenAIResponse(chunk, state) { if (part.text !== undefined && part.text !== "") { let accumulated = (state.textualToolCallBuffer || "") + part.text; - let candidate = null; - if (!state.hasEmittedContent || state.textualToolCallBuffer) { - candidate = parseTextualToolCallCandidate(accumulated); - } + let candidate = parseTextualToolCallCandidate(accumulated); if (candidate) { - const normalized = accumulated.replace(/[​-‍]/g, ""); - let toolCallIndex = normalized.lastIndexOf("[Tool call:"); + accumulated = accumulated.replace(/[\u200B-\u200D\uFEFF]/g, ""); + let toolCallIndex = accumulated.lastIndexOf("(empty)[Tool call:"); if (toolCallIndex < 0) { - toolCallIndex = normalized.lastIndexOf("(empty)[Tool call:"); + toolCallIndex = accumulated.lastIndexOf("[Tool call:"); } if (toolCallIndex < 0) { - const lastBracket = normalized.lastIndexOf("["); - if (lastBracket !== -1 && "[Tool call:".startsWith(normalized.slice(lastBracket))) { - toolCallIndex = lastBracket; + const lastParen = accumulated.lastIndexOf("("); + if ( + lastParen !== -1 && + "(empty)[Tool call:".startsWith(accumulated.slice(lastParen)) + ) { + toolCallIndex = lastParen; } else { - const lastParen = normalized.lastIndexOf("("); - if ( - lastParen !== -1 && - "(empty)[Tool call:".startsWith(normalized.slice(lastParen)) - ) { - toolCallIndex = lastParen; + const lastBracket = accumulated.lastIndexOf("["); + if (lastBracket !== -1 && "[Tool call:".startsWith(accumulated.slice(lastBracket))) { + toolCallIndex = lastBracket; } } } diff --git a/open-sse/utils/textualToolCall.ts b/open-sse/utils/textualToolCall.ts index 160f93e3d6b8..67c7fde1a2e1 100644 --- a/open-sse/utils/textualToolCall.ts +++ b/open-sse/utils/textualToolCall.ts @@ -61,14 +61,14 @@ export function parseTextualToolCallCandidate( const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); const toolCallIndex = normalized.lastIndexOf("[Tool call:"); if (toolCallIndex < 0) { - const lastBracket = normalized.lastIndexOf("["); - if (lastBracket !== -1 && "[Tool call:".startsWith(normalized.slice(lastBracket))) { - return { kind: "partial" }; - } const lastParen = normalized.lastIndexOf("("); if (lastParen !== -1 && "(empty)[Tool call:".startsWith(normalized.slice(lastParen))) { return { kind: "partial" }; } + const lastBracket = normalized.lastIndexOf("["); + if (lastBracket !== -1 && "[Tool call:".startsWith(normalized.slice(lastBracket))) { + return { kind: "partial" }; + } return null; } const candidate = normalized.slice(toolCallIndex); @@ -99,3 +99,14 @@ export function parseTextualToolCallCandidate( } return { kind: "partial" }; } + +export function containsTextualToolCallMarker(text: unknown): boolean { + if (typeof text !== "string") return false; + const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + + if (!normalized.includes("[Tool call:")) return false; + if (normalized.includes("Arguments:")) return true; + + const trimmed = normalized.trim(); + return trimmed.startsWith("[Tool call:") || trimmed.startsWith("(empty)[Tool call:"); +} diff --git a/tests/unit/translator-resp-gemini-to-openai.test.ts b/tests/unit/translator-resp-gemini-to-openai.test.ts index 094390807ec6..8398903ce573 100644 --- a/tests/unit/translator-resp-gemini-to-openai.test.ts +++ b/tests/unit/translator-resp-gemini-to-openai.test.ts @@ -814,3 +814,200 @@ test("Gemini stream: splits mid-stream partial candidate but preserves tool call assert.equal(toolCall.function.name, "read_file"); assert.equal(toolCall.function.arguments, '{"path":"/tmp/a"}'); }); + +test("Gemini stream: index mismatch regression test with zero-width characters in prefix", () => { + const state = createStreamingState(); + const result = geminiToOpenAIResponse( + { + responseId: "resp-textual-tool-index-mismatch", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: "\u200BКак исправить: [Tool call: terminal]\nArguments: {\"command\":\"whoami\"}", + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + state + ); + + const leakedContent = result.map((event: any) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(leakedContent, "Как исправить: "); + + const toolCalls = result.flatMap((event: any) => event.choices?.[0]?.delta?.tool_calls || []); + assert.equal(toolCalls.length, 1); + assert.equal(toolCalls[0].function.name, "terminal"); + assert.equal(toolCalls[0].function.arguments, '{"command":"whoami"}'); +}); + +test("Gemini stream: partial tool call with (empty) prefix check at chunk end does not leak (empty)", () => { + const state = createStreamingState(); + const chunk1 = { + responseId: "resp-empty-leak", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: "Результат: (empty)[Tool ca", + }, + ], + }, + }, + ], + }; + + const chunk2 = { + candidates: [ + { + content: { + parts: [ + { + text: "ll: terminal]\nArguments: {\"command\":\"whoami\"}", + }, + ], + }, + finishReason: "STOP", + }, + ], + }; + + const res1 = geminiToOpenAIResponse(chunk1, state) || []; + const content1 = res1.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(content1, "Результат: "); // (empty) must be buffered, not leaked! + + const res2 = geminiToOpenAIResponse(chunk2, state) || []; + const content2 = res2.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(content2, ""); + + assert.equal(state.toolCalls.size, 1); + const toolCall: any = Array.from(state.toolCalls.values())[0]; + assert.equal(toolCall.function.name, "terminal"); + assert.equal(toolCall.function.arguments, '{"command":"whoami"}'); +}); + +test("Gemini stream: parses textual tool call that starts in a subsequent chunk after prose has been emitted", () => { + const state = createStreamingState() as any; + const chunk1 = { + responseId: "resp-test-after-prose", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: "Generating response now... ", + }, + ], + }, + }, + ], + }; + + const chunk2 = { + candidates: [ + { + content: { + parts: [ + { + text: '[Tool call: web_search]\nArguments: {"query": "AI news"}', + }, + ], + }, + finishReason: "STOP", + }, + ], + }; + + const res1 = geminiToOpenAIResponse(chunk1, state) || []; + const content1 = res1.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(content1, "Generating response now... "); + + const res2 = geminiToOpenAIResponse(chunk2, state) || []; + const content2 = res2.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(content2, ""); + + assert.equal(state.toolCalls.size, 1); + const toolCall: any = Array.from(state.toolCalls.values())[0]; + assert.equal(toolCall.function.name, "web_search"); + assert.equal(toolCall.function.arguments, '{"query":"AI news"}'); +}); + +test("Gemini stream: checks lastParen before lastBracket when identifying partial (empty) markers with distinct chuncks", () => { + const state = createStreamingState() as any; + + // Имитируем чанк, который кончается на частичный "(empty)[Tool call:" маркер, например "(em" + const chunk1 = { + responseId: "resp-test-empty-partial", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: "Result is here: (em", + }, + ], + }, + }, + ], + }; + + // Имитируем чанк, который содержит и "(", и "[", кончаясь на "(empty)[Tool" + // Если бы мы проверяли lastBracket первым, мы бы отрезали по "[", оставив "(empty)" утекать пользователю. + const chunk2 = { + candidates: [ + { + content: { + parts: [ + { + text: "pty)[Tool", + }, + ], + }, + }, + ], + }; + + const chunk3 = { + candidates: [ + { + content: { + parts: [ + { + text: ' call: my_tool]\nArguments: {}', + }, + ], + }, + finishReason: "STOP", + }, + ], + }; + + const res1 = geminiToOpenAIResponse(chunk1, state) || []; + const content1 = res1.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(content1, "Result is here: "); // (em задерживается в буфере + + const res2 = geminiToOpenAIResponse(chunk2, state) || []; + const content2 = res2.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(content2, ""); // (empty)[Tool задерживается в буфере полностью, (empty) не утекает + + const res3 = geminiToOpenAIResponse(chunk3, state) || []; + const content3 = res3.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(content3, ""); + + assert.equal(state.toolCalls.size, 1); + const toolCall: any = Array.from(state.toolCalls.values())[0]; + assert.equal(toolCall.function.name, "my_tool"); + assert.equal(toolCall.function.arguments, "{}"); +}); + + + From 13224113433329515966f314a9961f33fe12c8bb Mon Sep 17 00:00:00 2001 From: Hernan Javier Ardila Sanchez Date: Mon, 8 Jun 2026 23:51:22 +0200 Subject: [PATCH 21/34] feat(opencode-plugin): per-prefix API format + debug logging + free-label normaliser (3 mrmm-fork backports) (#3420) Integrated into release/v3.8.17 --- @omniroute/opencode-plugin/package.json | 2 +- @omniroute/opencode-plugin/src/index.ts | 384 +++++++++++++++++- .../tests/fork-features.test.ts | 291 +++++++++++++ 3 files changed, 669 insertions(+), 8 deletions(-) create mode 100644 @omniroute/opencode-plugin/tests/fork-features.test.ts diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json index 22d50af18313..8489bbd9e13b 100644 --- a/@omniroute/opencode-plugin/package.json +++ b/@omniroute/opencode-plugin/package.json @@ -26,7 +26,7 @@ "scripts": { "build": "tsup", "clean": "rm -rf dist", - "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts", + "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts", "prepublishOnly": "npm run clean && npm run build && npm test" }, "keywords": [ diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index c6cc851058e5..68b9a648f73d 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -49,6 +49,10 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@opencode-ai/plugin"; import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; import { z } from "zod"; @@ -107,7 +111,38 @@ import { z } from "zod"; * preferred over the chat/inference key. * - `fetchInterceptor` Inject Authorization: Bearer + Content-Type on * every outbound request to baseURL. Default true. + * - `debugLog` Capture every outbound request + response to a + * JSONL file at + * `~/.local/share/opencode/plugins/omniroute-debug-{providerId}.jsonl`. + * Each line: `{ reqId, ts, url, method, reqBody, + * resStatus, resBody, durationMs }`. + * Default false. Opt-in. + * - `apiFormat` Per-provider-prefix API format routing. Model IDs + * whose prefix (the part before `/`) matches an entry + * in `anthropicPrefixes` are served via the Anthropic + * SDK (`@ai-sdk/anthropic`, sends to `/v1/messages` + * with native cache_control, tool_choice, etc.). + * All other models fall back to `openai-compatible`. + * + * Default `anthropicPrefixes`: + * ["cc", "claude", "anthropic", "kiro", "kr"] + * (covers OmniRoute's canonical Anthropic aliases). + * + * Set `anthropicPrefixes: []` to disable and force + * everything through OpenAI-compat. + * + * Example: + * ```json + * "apiFormat": { "anthropicPrefixes": ["cc","claude","anthropic","kiro"] } + * ``` */ +const apiFormatSchema = z + .object({ + anthropicPrefixes: z.array(z.string()).optional(), + }) + .strict() + .optional(); + const featuresSchema = z .object({ combos: z.boolean().optional(), @@ -120,6 +155,8 @@ const featuresSchema = z usableOnly: z.boolean().optional(), diskCache: z.boolean().optional(), providerTag: z.boolean().optional(), + debugLog: z.boolean().optional(), + apiFormat: apiFormatSchema, }) .strict(); @@ -238,6 +275,98 @@ function coercePluginOptions(opts?: PluginOptions): OmniRoutePluginOptions { return parseOmniRoutePluginOptions(opts); } +// ──────────────────────────────────────────────────────────────────────────── +// Per-prefix API format routing (apiFormat feature) +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Default provider-prefix list that triggers the Anthropic SDK format. + * Covers OmniRoute's canonical Anthropic aliases: `cc/`, `claude/`, + * `anthropic/`, plus the user-configured `kiro/` and `kr/` upstream + * connections that proxy Anthropic models. + */ +export const DEFAULT_ANTHROPIC_PREFIXES = [ + "cc", + "claude", + "anthropic", + "kiro", + "kr", +]; + +/** + * Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs + * `/v1/chat/completions` correctly. The Anthropic SDK does NOT want `/v1` + * (it appends `/v1/messages` automatically), so callers should branch on + * format first. + */ +export function ensureV1Suffix(url: string): string { + const trimmed = trimTrailingSlashes(url); + return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; +} + +/** + * Resolve the API block (id + url + npm package) for a given model id. + * + * Decision matrix: + * - If the model id's prefix (the substring before the first `/`) is in + * `apiFormat.anthropicPrefixes` (or the default list), return the + * Anthropic SDK block: `id: "anthropic"`, `url: baseURL` (no `/v1`), + * `npm: "@ai-sdk/anthropic"`. + * - Otherwise return the OpenAI-compat block: `id: "openai-compatible"`, + * `url: baseURL + "/v1"`, `npm: "@ai-sdk/openai-compatible"`. + * + * Combos span multiple providers. Callers should pass each combo member's + * id through this function and pick the LCD format (lowest common + * denominator that every upstream actually understands). + */ +export function resolveApiBlock( + modelId: string, + baseURL: string, + apiFormat?: { anthropicPrefixes?: string[] } +): { id: string; url: string; npm: string } { + const prefixes = apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES; + const slash = modelId.indexOf("/"); + const prefix = slash === -1 ? modelId : modelId.slice(0, slash); + const isAnthropic = prefixes.includes(prefix); + return isAnthropic + ? { + id: "anthropic", + url: trimTrailingSlashes(baseURL), + npm: "@ai-sdk/anthropic", + } + : { + id: "openai-compatible", + url: ensureV1Suffix(baseURL), + npm: "@ai-sdk/openai-compatible", + }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Free-label normalisation +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Normalise a model display name so free-tier models always carry a + * consistent `[Free] ` prefix instead of a trailing `(Free)` suffix or + * an ad-hoc `free` word anywhere in the name. + * + * "GPT-4.1 (Free)" → "[Free] GPT-4.1" + * "DeepSeek V4 Flash Free" → "[Free] DeepSeek V4 Flash" + * "Claude 4.7 Opus" → "Claude 4.7 Opus" (no change) + * + * Non-matching names pass through untouched. + */ +export function normaliseFreeLabel(name: string): string { + // Remove trailing " (Free)" or " free" (case-insensitive, hyphen-tolerant) + const cleaned = name + .replace(/\s*\(free\)\s*$/i, "") + .replace(/[\s-]+free\s*$/i, "") + .trim(); + const wasFree = cleaned.length < name.trim().length; + if (!wasFree) return name; + return `[Free] ${cleaned}`; +} + /** * Build the AuthHook portion of the plugin for a given options bag. Exported * standalone so the auth contract can be unit-tested without faking the full @@ -270,6 +399,7 @@ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook // documented and schema-validated but silently ignored. const wantFetchInterceptor = (features ?? {}).fetchInterceptor !== false; const wantGeminiSanitization = (features ?? {}).geminiSanitization !== false; + const wantDebugLog = (features ?? {}).debugLog === true; const hook: AuthHook = { provider: providerId, @@ -330,6 +460,13 @@ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook if (wantGeminiSanitization) { composedFetch = createGeminiSanitizingFetch(composedFetch ?? fetch); } + if (wantDebugLog || debugLogEnabled(providerId)) { + composedFetch = createDebugLoggingFetch( + composedFetch ?? fetch, + providerId, + wantDebugLog + ); + } return composedFetch ? { apiKey, baseURL: resolvedBaseURL, fetch: composedFetch } : { apiKey, baseURL: resolvedBaseURL }; @@ -561,7 +698,7 @@ export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( export function mapRawModelToModelV2( raw: OmniRouteRawModelEntry, - ctx: { providerId: string; baseURL: string } + ctx: { providerId: string; baseURL: string; apiFormat?: { anthropicPrefixes?: string[] } } ): ModelV2 { const caps = raw.capabilities ?? {}; const inMods = new Set(raw.input_modalities ?? ["text"]); @@ -612,11 +749,7 @@ export function mapRawModelToModelV2( headers: {}, release_date: raw.release_date ?? "", providerID: ctx.providerId, - api: { - id: "openai-compatible", - url: ctx.baseURL, - npm: "@ai-sdk/openai-compatible", - }, + api: resolveApiBlock(raw.id, ctx.baseURL, ctx.apiFormat), }; } @@ -1410,7 +1543,7 @@ export function applyEnrichment( ): ModelV2 { if (!enrichment) return model; if (enrichment.name && enrichment.name.trim().length > 0) { - model.name = enrichment.name; + model.name = normaliseFreeLabel(enrichment.name); } if (enrichment.pricing) { if (typeof enrichment.pricing.input === "number") { @@ -2134,6 +2267,7 @@ export function createOmniRouteProviderHook( const model = mapRawModelToModelV2(entry, { providerId: resolved.providerId, baseURL, + apiFormat: resolved.features?.apiFormat, }); const enrichEntry = lookupEnrichment(entry.id, rawEnrichment, canonicalToAlias); applyEnrichment(model, enrichEntry); @@ -3186,6 +3320,242 @@ export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async (pro /** No-op disk-cache pair — used by tests to avoid filesystem side effects. */ export const noopDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {}; + +// ──────────────────────────────────────────────────────────────────────────── +// Debug logging (features.debugLog) +// ──────────────────────────────────────────────────────────────────────────── + +/** + * One captured request/response pair written to the debug JSONL log. + * Schema documented in the schema-aware `DebugLogEntry` interface below. + */ +export interface DebugLogEntry { + reqId: string; + providerId: string; + ts: number; + url: string; + method: string; + reqHeaders: Record; + reqBody: unknown; + resStatus: number | null; + resHeaders: Record; + resBody: unknown; + durationMs: number | null; + error?: string; +} + +function debugLogDir(): string { + return join( + process.env.OPENCODE_DATA_DIR ?? join(homedir(), ".local", "share", "opencode"), + "plugins" + ); +} + +function debugLogPath(providerId: string): string { + return join(debugLogDir(), `omniroute-debug-${providerId}.jsonl`); +} + +function debugStatePath(providerId: string): string { + return join(debugLogDir(), `omniroute-debug-${providerId}.state.json`); +} + +export function debugLogEnabled(providerId: string): boolean { + try { + const p = debugStatePath(providerId); + if (!existsSync(p)) return false; + const s = JSON.parse(readFileSync(p, "utf8")) as { enabled?: boolean }; + return s.enabled === true; + } catch { + return false; + } +} + +export function debugLogSetEnabled(providerId: string, enabled: boolean): void { + try { + const dir = debugLogDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync( + debugStatePath(providerId), + JSON.stringify({ enabled, ts: Date.now() }, null, 2) + ); + } catch (err) { + // best-effort; never break the auth flow + console.warn(`[omniroute-plugin] debugLogSetEnabled failed: ${(err as Error).message}`); + } +} + +export function debugLogAppend(entry: DebugLogEntry): void { + try { + const dir = debugLogDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + appendFileSync(debugLogPath(entry.providerId), JSON.stringify(entry) + "\n", "utf8"); + } catch (err) { + console.warn(`[omniroute-plugin] debugLogAppend failed: ${(err as Error).message}`); + } +} + +export function debugLogRead(providerId: string, limit = 20): DebugLogEntry[] { + try { + const p = debugLogPath(providerId); + if (!existsSync(p)) return []; + const lines = readFileSync(p, "utf8").trim().split("\n").filter(Boolean); + return lines + .slice(-limit) + .map((line) => { + try { + return JSON.parse(line) as DebugLogEntry; + } catch { + return null; + } + }) + .filter((e): e is DebugLogEntry => e !== null); + } catch { + return []; + } +} + +export function debugLogGetById(providerId: string, reqId: string): DebugLogEntry | null { + try { + const p = debugLogPath(providerId); + if (!existsSync(p)) return null; + const lines = readFileSync(p, "utf8").trim().split("\n").filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + try { + const e = JSON.parse(lines[i]) as DebugLogEntry; + if (e.reqId === reqId) return e; + } catch { + // skip malformed + } + } + return null; + } catch { + return null; + } +} + +export function debugLogClear(providerId: string): void { + try { + const p = debugLogPath(providerId); + if (existsSync(p)) writeFileSync(p, "", "utf8"); + } catch (err) { + console.warn(`[omniroute-plugin] debugLogClear failed: ${(err as Error).message}`); + } +} + +/** + * Wrap a fetch function to capture request/response pairs into the debug + * JSONL log. Honours the `featureDefault` opt-in flag and the on-disk + * runtime toggle (`debugLogEnabled`). + */ +export function createDebugLoggingFetch( + inner: typeof fetch, + providerId: string, + featureDefault: boolean +): typeof fetch { + return async (input, init) => { + const active = featureDefault || debugLogEnabled(providerId); + if (!active) return inner(input, init); + const reqId = randomUUID(); + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input instanceof Request + ? input.url + : String(input); + const method = (init?.method ?? (typeof input === "string" ? "GET" : (input as Request).method ?? "GET")).toUpperCase(); + const reqHeaders: Record = {}; + if (input instanceof Request) { + input.headers.forEach((v, k) => (reqHeaders[k] = v)); + } + if (init?.headers) { + const h = init.headers; + if (h instanceof Headers) h.forEach((v, k) => (reqHeaders[k] = v)); + else if (Array.isArray(h)) for (const [k, v] of h) reqHeaders[k] = v; + else Object.assign(reqHeaders, h); + } + let reqBody: unknown = undefined; + if (init?.body) { + if (typeof init.body === "string") { + try { + reqBody = JSON.parse(init.body); + } catch { + reqBody = init.body.slice(0, 4096); + } + } else { + reqBody = "[non-string body]"; + } + } else if (input instanceof Request) { + try { + const clonedReq = input.clone(); + const text = await clonedReq.text(); + try { + reqBody = JSON.parse(text); + } catch { + reqBody = text.slice(0, 4096); + } + } catch { + reqBody = "[body unreadable]"; + } + } + const t0 = Date.now(); + try { + const res = await inner(input, init); + const durationMs = Date.now() - t0; + const resHeaders: Record = {}; + res.headers.forEach((v, k) => (resHeaders[k] = v)); + let resBody: unknown = undefined; + try { + const clone = res.clone(); + const ct = clone.headers.get("content-type") ?? ""; + if (ct.includes("application/json")) { + resBody = await clone.json(); + } else if (ct.includes("text/event-stream")) { + resBody = "[stream]"; + } else if (ct.includes("text/")) { + const txt = await clone.text(); + resBody = txt.length > 4096 ? txt.slice(0, 4096) + "...[truncated]" : txt; + } else { + resBody = `[${ct || "unknown"} body, status ${res.status}]`; + } + } catch { + resBody = "[body unparseable]"; + } + debugLogAppend({ + reqId, + providerId, + ts: t0, + url, + method, + reqHeaders, + reqBody, + resStatus: res.status, + resHeaders, + resBody, + durationMs, + }); + return res; + } catch (err) { + const durationMs = Date.now() - t0; + debugLogAppend({ + reqId, + providerId, + ts: t0, + url, + method, + reqHeaders, + reqBody, + resStatus: null, + resHeaders: {}, + resBody: undefined, + durationMs, + error: (err as Error).message, + }); + throw err; + } + }; +} export const noopDiskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined; export type OmniRouteReadAuthJson = () => Promise; diff --git a/@omniroute/opencode-plugin/tests/fork-features.test.ts b/@omniroute/opencode-plugin/tests/fork-features.test.ts new file mode 100644 index 000000000000..03a6262cf8a6 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/fork-features.test.ts @@ -0,0 +1,291 @@ +/** + * Tests for the 3 mrmm-fork features backported to @omniroute/opencode-plugin: + * + * 1. `normaliseFreeLabel` — free-tier model display names get a consistent + * `[Free] ` prefix instead of trailing "(Free)" or ad-hoc "free" words. + * + * 2. `resolveApiBlock` — per-provider-prefix API format routing. Anthropic + * prefixes (`cc/`, `claude/`, `anthropic/`, `kiro/`, `kr/`) get the + * Anthropic SDK block; everything else gets OpenAI-compat. + * + * 3. `debugLog` — JSONL request/response capture, gated by + * `features.debugLog` and togglable at runtime via + * `debugLogEnabled/SetEnabled`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + normaliseFreeLabel, + resolveApiBlock, + DEFAULT_ANTHROPIC_PREFIXES, + ensureV1Suffix, + debugLogEnabled, + debugLogSetEnabled, + debugLogClear, + debugLogRead, + debugLogAppend, + createDebugLoggingFetch, + DebugLogEntry, +} from "../src/index.js"; + +// ── 1. normaliseFreeLabel ──────────────────────────────────────────────────── + +test("normaliseFreeLabel: '(Free)' suffix becomes [Free] prefix", () => { + assert.equal(normaliseFreeLabel("GPT-4.1 (Free)"), "[Free] GPT-4.1"); +}); + +test("normaliseFreeLabel: trailing ' Free' word becomes [Free] prefix", () => { + assert.equal( + normaliseFreeLabel("DeepSeek V4 Flash Free"), + "[Free] DeepSeek V4 Flash" + ); +}); + +test("normaliseFreeLabel: trailing '-free' (hyphen) becomes [Free] prefix", () => { + assert.equal(normaliseFreeLabel("Llama 4 Scout-free"), "[Free] Llama 4 Scout"); +}); + +test("normaliseFreeLabel: case-insensitive (FREE, Free, free all match)", () => { + assert.equal(normaliseFreeLabel("Model A FREE"), "[Free] Model A"); + assert.equal(normaliseFreeLabel("Model A free"), "[Free] Model A"); + assert.equal(normaliseFreeLabel("Model A Free"), "[Free] Model A"); +}); + +test("normaliseFreeLabel: names without 'free' pass through unchanged", () => { + assert.equal(normaliseFreeLabel("Claude 4.7 Opus"), "Claude 4.7 Opus"); + assert.equal(normaliseFreeLabel("GPT-5"), "GPT-5"); +}); + +test("normaliseFreeLabel: 'free' in the middle of a name is NOT rewritten", () => { + // Only trailing/standalone "free" markers count; embedded "freedom" stays + assert.equal( + normaliseFreeLabel("Freedom Model"), + "Freedom Model" + ); +}); + +test("normaliseFreeLabel: empty / whitespace-only inputs are handled", () => { + // Empty input returns empty; pure whitespace input passes through (no Free marker) + assert.equal(normaliseFreeLabel(""), ""); + assert.equal(normaliseFreeLabel(" "), " "); +}); + +// ── 2. resolveApiBlock ─────────────────────────────────────────────────────── + +test("resolveApiBlock: cc/* models get the Anthropic SDK block (no /v1)", () => { + const block = resolveApiBlock("cc/claude-opus-4-7", "https://api.example.com"); + assert.equal(block.id, "anthropic"); + assert.equal(block.npm, "@ai-sdk/anthropic"); + assert.equal(block.url, "https://api.example.com"); // NO /v1 suffix +}); + +test("resolveApiBlock: claude/*, anthropic/*, kiro/*, kr/* all route to Anthropic", () => { + for (const id of [ + "claude/claude-opus-4-7", + "anthropic/claude-sonnet-4", + "kiro/claude-sonnet-4-5", + "kr/claude-opus-4-6", + ]) { + const block = resolveApiBlock(id, "https://api.example.com"); + assert.equal(block.id, "anthropic", `${id} should route to Anthropic`); + assert.equal(block.npm, "@ai-sdk/anthropic"); + } +}); + +test("resolveApiBlock: non-Anthropic models get OpenAI-compat with /v1", () => { + const block = resolveApiBlock("gpt-4o", "https://api.example.com"); + assert.equal(block.id, "openai-compatible"); + assert.equal(block.npm, "@ai-sdk/openai-compatible"); + assert.equal(block.url, "https://api.example.com/v1"); +}); + +test("resolveApiBlock: user can override anthropicPrefixes to add custom prefixes", () => { + const block = resolveApiBlock("myproxy/claude-opus", "https://api.example.com", { + anthropicPrefixes: ["myproxy"], + }); + assert.equal(block.id, "anthropic"); + assert.equal(block.npm, "@ai-sdk/anthropic"); +}); + +test("resolveApiBlock: empty anthropicPrefixes forces OpenAI-compat for everything", () => { + const block = resolveApiBlock("cc/claude-opus", "https://api.example.com", { + anthropicPrefixes: [], + }); + assert.equal(block.id, "openai-compatible"); +}); + +test("resolveApiBlock: baseURL that already ends in /v1 is not double-suffixed (OpenAI path)", () => { + const block = resolveApiBlock("gpt-4o", "https://api.example.com/v1"); + assert.equal(block.url, "https://api.example.com/v1"); // idempotent +}); + +test("resolveApiBlock: model id without '/' uses the id as prefix", () => { + const block = resolveApiBlock("claude-opus-4-7", "https://api.example.com"); + // The whole id is the prefix, which doesn't match "cc"/"claude" etc. + // So it falls through to OpenAI-compat. + assert.equal(block.id, "openai-compatible"); +}); + +test("DEFAULT_ANTHROPIC_PREFIXES: contains the canonical Anthropic aliases", () => { + assert.deepEqual(DEFAULT_ANTHROPIC_PREFIXES, [ + "cc", + "claude", + "anthropic", + "kiro", + "kr", + ]); +}); + +test("ensureV1Suffix: idempotent for URLs that already end in /v1", () => { + assert.equal(ensureV1Suffix("https://api.example.com/v1"), "https://api.example.com/v1"); + assert.equal( + ensureV1Suffix("https://api.example.com/v1/"), + "https://api.example.com/v1" // trailing slash is stripped + ); +}); + +test("ensureV1Suffix: appends /v1 when missing", () => { + assert.equal(ensureV1Suffix("https://api.example.com"), "https://api.example.com/v1"); + assert.equal(ensureV1Suffix("https://api.example.com/"), "https://api.example.com/v1"); +}); + +// ── 3. debugLog ────────────────────────────────────────────────────────────── + +test("debugLog: default state is disabled", () => { + debugLogClear("test-provider-disabled-default"); + assert.equal(debugLogEnabled("test-provider-disabled-default"), false); +}); + +test("debugLogSetEnabled + debugLogEnabled: roundtrip", () => { + debugLogSetEnabled("test-provider-toggle", true); + assert.equal(debugLogEnabled("test-provider-toggle"), true); + debugLogSetEnabled("test-provider-toggle", false); + assert.equal(debugLogEnabled("test-provider-toggle"), false); +}); + +test("debugLogAppend + debugLogRead: roundtrip preserves entry shape", () => { + const providerId = "test-provider-readroundtrip"; + debugLogClear(providerId); + const entry: DebugLogEntry = { + reqId: "req-1", + providerId, + ts: 1700000000000, + url: "https://api.example.com/v1/chat", + method: "POST", + reqHeaders: { "content-type": "application/json" }, + reqBody: { model: "gpt-4o", messages: [] }, + resStatus: 200, + resHeaders: { "content-type": "application/json" }, + resBody: { choices: [] }, + durationMs: 42, + }; + debugLogAppend(entry); + const read = debugLogRead(providerId, 10); + assert.equal(read.length, 1); + assert.deepEqual(read[0], entry); +}); + +test("createDebugLoggingFetch: passes through when disabled", async () => { + const providerId = "test-provider-passthrough"; + debugLogClear(providerId); + debugLogSetEnabled(providerId, false); + const calls: unknown[] = []; + const inner: typeof fetch = async (input) => { + calls.push(input); + return new Response("ok", { status: 200 }); + }; + const wrapped = createDebugLoggingFetch(inner, providerId, false); + const res = await wrapped("https://api.example.com/v1/chat"); + assert.equal(res.status, 200); + assert.equal(calls.length, 1); + // No log entry should be written when disabled + assert.equal(debugLogRead(providerId).length, 0); +}); + +test("createDebugLoggingFetch: captures request/response when enabled", async () => { + const providerId = "test-provider-captures"; + debugLogClear(providerId); + const inner: typeof fetch = async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + const wrapped = createDebugLoggingFetch(inner, providerId, true); + const res = await wrapped("https://api.example.com/v1/chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-4o" }), + }); + assert.equal(res.status, 200); + const entries = debugLogRead(providerId); + assert.equal(entries.length, 1); + assert.equal(entries[0].method, "POST"); + assert.equal(entries[0].resStatus, 200); + assert.equal(entries[0].url, "https://api.example.com/v1/chat"); + assert.deepEqual(entries[0].reqBody, { model: "gpt-4o" }); +}); + +test("createDebugLoggingFetch: records error without crashing the wrapped fetch", async () => { + const providerId = "test-provider-error"; + debugLogClear(providerId); + const inner: typeof fetch = async () => { + throw new Error("network down"); + }; + const wrapped = createDebugLoggingFetch(inner, providerId, true); + await assert.rejects(wrapped("https://api.example.com/v1/chat"), /network down/); + const entries = debugLogRead(providerId); + assert.equal(entries.length, 1); + assert.equal(entries[0].resStatus, null); + assert.equal(entries[0].error, "network down"); +}); + +// ── Regression tests for the 3 HIGH-priority bot review fixes ─────────────── + +test("createDebugLoggingFetch: URL instance input is captured (not 'undefined')", async () => { + const providerId = "test-provider-url-input"; + debugLogClear(providerId); + const inner: typeof fetch = async () => + new Response("ok", { status: 200 }); + const wrapped = createDebugLoggingFetch(inner, providerId, true); + await wrapped(new URL("https://api.example.com/v1/chat")); + const entries = debugLogRead(providerId); + assert.equal(entries.length, 1); + assert.equal(entries[0].url, "https://api.example.com/v1/chat"); + assert.notEqual(entries[0].url, undefined); +}); + +test("createDebugLoggingFetch: Request object input captures URL and headers", async () => { + const providerId = "test-provider-request-input"; + debugLogClear(providerId); + const inner: typeof fetch = async () => + new Response("ok", { status: 200 }); + const wrapped = createDebugLoggingFetch(inner, providerId, true); + const req = new Request("https://api.example.com/v1/chat", { + method: "POST", + headers: { "x-test": "yes" }, + }); + await wrapped(req); + const entries = debugLogRead(providerId); + assert.equal(entries.length, 1); + assert.equal(entries[0].url, "https://api.example.com/v1/chat"); + assert.equal(entries[0].reqHeaders["x-test"], "yes"); +}); + +test("createDebugLoggingFetch: SSE response is NOT buffered (resBody is the stream marker)", async () => { + const providerId = "test-provider-sse"; + debugLogClear(providerId); + const inner: typeof fetch = async () => + new Response("data: hello\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + const wrapped = createDebugLoggingFetch(inner, providerId, true); + const res = await wrapped("https://api.example.com/v1/stream"); + // The response body must remain readable downstream + const txt = await res.text(); + assert.equal(txt, "data: hello\n\n"); + const entries = debugLogRead(providerId); + assert.equal(entries.length, 1); + assert.equal(entries[0].resBody, "[stream]", "SSE responses must not be buffered"); +}); From 617a648088ecb0bf56ab4f29ddee50607d70bebc Mon Sep 17 00:00:00 2001 From: ViFigueiredo <67883343+ViFigueiredo@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:04:36 -0300 Subject: [PATCH 22/34] fix(translator): use non-empty reasoning_content placeholder on cache miss instead of empty string (#3433) Integrated into release/v3.8.17 --- open-sse/translator/index.ts | 12 +++-- src/app/api/system/version/route.ts | 79 ++++++++++++++++------------- src/lib/system/autoUpdate.ts | 23 +++++++-- tests/unit/reasoning-cache.test.ts | 56 ++++++++++++++++++++ 4 files changed, 127 insertions(+), 43 deletions(-) diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 65341d5393e7..0b6501da8c41 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -294,7 +294,6 @@ export function translateRequest( thinkingEnabled: hasThinkingConfig(result), supportsReasoning: supportsReasoning({ provider: normalizedProvider, model: normalizedModel }), interleavedField: resolvedCapabilities?.interleavedField ?? null, - allowLegacyFallback: false, }); if (isReasoner && result.messages && Array.isArray(result.messages)) { const canReplayReasoningOnly = isDeepSeekReplayTarget(normalizedProvider, normalizedModel); @@ -370,9 +369,14 @@ export function translateRequest( } } - // Legacy fallback — empty string (works for older DeepSeek versions) - if (hasToolCalls && msg.reasoning_content === undefined) { - msg.reasoning_content = ""; + // Cache miss fallback — use a non-empty placeholder. + // Empty string causes DeepSeek V4+ to reject with 400: + // "reasoning_content in the thinking mode must be passed back to the API." + // Note: injectEmptyReasoningContentForToolCalls may have pre-set + // reasoning_content="" before the cache lookup, so we check for + // both undefined AND empty string here. + if (hasToolCalls && !msg.reasoning_content) { + msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER; } } } else if ( diff --git a/src/app/api/system/version/route.ts b/src/app/api/system/version/route.ts index 94e4cda28bcf..637b1d34ec05 100644 --- a/src/app/api/system/version/route.ts +++ b/src/app/api/system/version/route.ts @@ -14,6 +14,7 @@ import { getAutoUpdateConfig, launchAutoUpdate, validateAutoUpdateRuntime, + PROJECT_ROOT, } from "@/lib/system/autoUpdate"; import { NEWS_JSON_URL, parseActiveNewsPayload } from "@/shared/utils/releaseNotes"; @@ -169,7 +170,7 @@ export async function POST(req: NextRequest) { }); await execFileAsync("git", ["fetch", "--tags", config.gitRemote], { timeout: 60_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); send({ step: "install", status: "done", message: "Tags fetched" }); @@ -178,7 +179,7 @@ export async function POST(req: NextRequest) { status: "running", message: `Validating ${resolvedTargetTag}...`, }); - await ensureGitTagExists(resolvedTargetTag, execFileAsync, process.cwd()); + await ensureGitTagExists(resolvedTargetTag, execFileAsync, PROJECT_ROOT); send({ step: "install", status: "done", @@ -193,7 +194,7 @@ export async function POST(req: NextRequest) { try { await execFileAsync("git", ["stash", "--include-untracked"], { timeout: 30_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); } catch { // No local changes to stash. @@ -202,7 +203,7 @@ export async function POST(req: NextRequest) { const shortHead = ( await execFileAsync("git", ["rev-parse", "--short", "HEAD"], { timeout: 10_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }) ).stdout.trim(); const backupBranch = `pre-update/${shortHead}-${new Date().toISOString().replace(/[:.]/g, "-")}`; @@ -210,7 +211,7 @@ export async function POST(req: NextRequest) { try { await execFileAsync("git", ["branch", backupBranch], { timeout: 10_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); } catch { // Backup branch is best-effort only. @@ -218,7 +219,7 @@ export async function POST(req: NextRequest) { await execFileAsync("git", ["checkout", resolvedTargetTag], { timeout: 30_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); send({ step: "install", status: "done", message: `Checked out ${resolvedTargetTag}` }); @@ -229,14 +230,14 @@ export async function POST(req: NextRequest) { }); await execFileAsync("npm", ["install", "--legacy-peer-deps"], { timeout: 300_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); send({ step: "rebuild", status: "done", message: "Dependencies installed" }); try { await execFileAsync("node", ["scripts/dev/sync-env.mjs"], { timeout: 15_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); } catch { // .env sync is non-fatal during update. @@ -249,7 +250,7 @@ export async function POST(req: NextRequest) { }); await execFileAsync("npm", ["run", "build"], { timeout: 600_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); send({ step: "rebuild", status: "done", message: "Build complete" }); @@ -257,7 +258,7 @@ export async function POST(req: NextRequest) { try { await execFileAsync("pm2", ["restart", "omniroute", "--update-env"], { timeout: 30_000, - cwd: process.cwd(), + cwd: PROJECT_ROOT, }); send({ step: "restart", status: "done", message: "Service restarted" }); } catch { @@ -306,13 +307,14 @@ export async function POST(req: NextRequest) { try { // Step 1: Install send({ step: "install", status: "running", message: `Installing omniroute@${latest}...` }); - await execFileAsync( - "npm", - ["install", "-g", `omniroute@${latest}`, "--ignore-scripts", "--legacy-peer-deps"], - { - timeout: 300000, - } - ); + await execFileAsync( + "npm", + ["install", "-g", `omniroute@${latest}`, "--ignore-scripts", "--legacy-peer-deps"], + { + timeout: 300000, + cwd: PROJECT_ROOT, + } + ); send({ step: "install", status: "done", message: `Installed omniroute@${latest}` }); // Step 2: Rebuild native modules (critical for better-sqlite3) @@ -321,29 +323,36 @@ export async function POST(req: NextRequest) { status: "running", message: "Rebuilding native modules (better-sqlite3)...", }); - const globalRoot = ( - await execFileAsync("npm", ["root", "-g"], { timeout: 10000 }) - ).stdout.trim(); + const globalRoot = ( + await execFileAsync("npm", ["root", "-g"], { timeout: 10000, cwd: PROJECT_ROOT }) + ).stdout.trim(); const omniPath = `${globalRoot}/omniroute/app`; - await execFileAsync("npm", ["rebuild", "better-sqlite3"], { - cwd: omniPath, - timeout: 120000, - }); + await execFileAsync( + "npm", + ["rebuild", "better-sqlite3"], + { + cwd: omniPath, + timeout: 120000, + } + ); send({ step: "rebuild", status: "done", message: "Native modules rebuilt" }); // Step 3: Restart PM2 send({ step: "restart", status: "running", message: "Restarting service via PM2..." }); - try { - await execFileAsync("pm2", ["restart", "omniroute", "--update-env"], { timeout: 30000 }); - send({ step: "restart", status: "done", message: "Service restarted" }); - } catch { - // PM2 may not be available (Docker/manual setups) - send({ - step: "restart", - status: "skipped", - message: "PM2 not available — manual restart needed", - }); - } + try { + await execFileAsync("pm2", ["restart", "omniroute", "--update-env"], { + timeout: 30000, + cwd: PROJECT_ROOT, + }); + send({ step: "restart", status: "done", message: "Service restarted" }); + } catch { + // PM2 may not be available (Docker/manual setups) + send({ + step: "restart", + status: "skipped", + message: "PM2 not available — manual restart needed", + }); + } send({ step: "complete", diff --git a/src/lib/system/autoUpdate.ts b/src/lib/system/autoUpdate.ts index bfa51c648be4..c3dad1ed4487 100644 --- a/src/lib/system/autoUpdate.ts +++ b/src/lib/system/autoUpdate.ts @@ -3,9 +3,24 @@ import { closeSync, mkdirSync, openSync, existsSync } from "node:fs"; import { access } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; +import { homedir } from "node:os"; const execFileAsync = promisify(execFile); +function resolveProjectRoot(fallback: string): string { + try { + const cwd = process.cwd(); + if (existsSync(path.join(cwd, "package.json"))) return cwd; + if (existsSync(path.join(cwd, ".git"))) return cwd; + return cwd; + } catch { + return fallback; + } +} + +const FALLBACK_CWD = process.env.HOME || homedir() || "/tmp"; +export const PROJECT_ROOT: string = resolveProjectRoot(FALLBACK_CWD); + type ComposeCommand = "docker compose" | "docker-compose"; export type AutoUpdateMode = "npm" | "docker-compose" | "source"; @@ -70,8 +85,8 @@ export function getAutoUpdateConfig(env: NodeJS.ProcessEnv = process.env): AutoU let mode = normalizeMode(env.AUTO_UPDATE_MODE); if (mode === "npm") { - const isGitRepo = existsSync(path.join(process.cwd(), ".git")); - const currentDir = typeof __dirname !== "undefined" ? __dirname : process.cwd(); + const isGitRepo = existsSync(path.join(PROJECT_ROOT, ".git")); + const currentDir = typeof __dirname !== "undefined" ? __dirname : PROJECT_ROOT; const isGlobalNodeModules = currentDir.includes("node_modules"); // If we are not in a global node_modules directory, we are likely a local source install/build. @@ -117,7 +132,7 @@ export async function validateAutoUpdateRuntime( existsImpl: (targetPath: string) => Promise = pathExists ): Promise { if (config.mode === "source") { - const gitDir = path.join(process.cwd(), ".git"); + const gitDir = path.join(PROJECT_ROOT, ".git"); if (!(await existsImpl(gitDir))) { return { supported: false, @@ -197,7 +212,7 @@ export async function validateAutoUpdateRuntime( export async function ensureGitTagExists( targetTag: string, execFileImpl: ExecFileLike = execFileAsync, - cwd = process.cwd() + cwd = PROJECT_ROOT ): Promise { try { await execFileImpl("git", ["rev-parse", "-q", "--verify", `refs/tags/${targetTag}`], { diff --git a/tests/unit/reasoning-cache.test.ts b/tests/unit/reasoning-cache.test.ts index e5c4c7e311d6..9731ac342805 100644 --- a/tests/unit/reasoning-cache.test.ts +++ b/tests/unit/reasoning-cache.test.ts @@ -754,6 +754,62 @@ describe("Reasoning Replay Cache — Translator Replay", () => { assert.equal(translated.messages[1].reasoning_content, undefined); }); + + it("should replace empty-string reasoning_content with NON_ANTHROPIC_THINKING_PLACEHOLDER on cache miss", async () => { + // Regression: injectEmptyReasoningContentForToolCalls (schemaCoercion.ts) pre-sets + // reasoning_content="" before the cache lookup. The old condition + // `msg.reasoning_content === undefined` never fired on cache miss, leaving the + // empty string in place. DeepSeek V4+ rejects "" with a 400. + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + deepseek: { + "deepseek-v4-flash": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, + }); + + const { NON_ANTHROPIC_THINKING_PLACEHOLDER } = await import( + "../../open-sse/translator/helpers/claudeHelper.ts" + ); + + // No cache entry → cache miss + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + "deepseek-v4-flash", + { + messages: [ + { role: "user", content: "use a tool" }, + { + role: "assistant", + content: null, + reasoning_content: "", + tool_calls: [ + { + id: "call_empty_rc", + type: "function", + function: { name: "read_file", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "call_empty_rc", content: "file contents" }, + ], + }, + false, + null, + "deepseek" + ); + + assert.equal( + translated.messages[1].reasoning_content, + NON_ANTHROPIC_THINKING_PLACEHOLDER, + "empty reasoning_content should be replaced with placeholder on cache miss" + ); + }); }); describe("Reasoning Replay Cache — API Route", () => { From 003e6a80b76059eec7e1069edc59e816759a9d2f Mon Sep 17 00:00:00 2001 From: "M.M" Date: Tue, 9 Jun 2026 00:08:17 +0200 Subject: [PATCH 23/34] feat(plugin+api): auto combos + free model quota display + /api/combos/auto (#3435) Integrated into release/v3.8.17 --- @omniroute/opencode-plugin/src/index.ts | 1899 +++++++++++++---- @omniroute/opencode-plugin/src/logger.ts | 74 + @omniroute/opencode-plugin/src/naming.ts | 296 +++ open-sse/services/autoCombo/autoPrefix.ts | 9 +- src/app/api/combos/auto/route.ts | 49 + src/app/api/free-models/route.ts | 27 + .../auto-combos-free-models-routes.test.ts | 139 ++ 7 files changed, 2060 insertions(+), 433 deletions(-) create mode 100644 @omniroute/opencode-plugin/src/logger.ts create mode 100644 @omniroute/opencode-plugin/src/naming.ts create mode 100644 src/app/api/combos/auto/route.ts create mode 100644 src/app/api/free-models/route.ts create mode 100644 tests/unit/auto-combos-free-models-routes.test.ts diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 68b9a648f73d..4074ebdace4c 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -56,6 +56,23 @@ import { randomUUID } from "node:crypto"; import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@opencode-ai/plugin"; import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; import { z } from "zod"; +import { + logger as _logger, + setLogLevel, + type LogLevel as _LogLevel, +} from "./logger.js"; +import { + PROVIDER_TAG_SEPARATOR as _PROVIDER_TAG_SEPARATOR, + shortProviderLabel as _shortProviderLabel, + normaliseFreeLabel as _normaliseFreeLabel, + formatAutoComboName, + autoComboModelId, + formatFreeBudget, + type AutoVariant, + AUTO_VARIANTS, + AUTO_VARIANT_DESCRIPTIONS, + type FreeModelFreeType, +} from "./naming.js"; /** * Zod schema for plugin options accepted as the second element of the @@ -146,6 +163,7 @@ const apiFormatSchema = z const featuresSchema = z .object({ combos: z.boolean().optional(), + autoCombos: z.boolean().optional(), enrichment: z.boolean().optional(), compressionMetadata: z.boolean().optional(), geminiSanitization: z.boolean().optional(), @@ -185,6 +203,16 @@ export type OmniRoutePluginOptions = z.infer; export const OMNIROUTE_PROVIDER_KEY = "omniroute" as const; +/** Deployed plugin version (injected at build time by tsup define). */ +export const PLUGIN_VERSION: string = + ((globalThis as Record).__PLUGIN_VERSION__ as string) ?? + "dev"; + +/** Deployed plugin git commit hash (injected at build time by tsup define). */ +export const PLUGIN_GIT_HASH: string = + ((globalThis as Record).__PLUGIN_GIT_HASH__ as string) ?? + "unknown"; + export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const; // Manual trim helpers avoid polynomial-regex CodeQL warnings on @@ -195,6 +223,60 @@ function trimTrailingSlashes(value: string): string { while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--; return i === value.length ? value : value.slice(0, i); } + +/** + * Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs + * `/v1/chat/completions` instead of `/chat/completions`. + * No-op when the URL already ends with `/v1` or `/v1/`. + */ +export function ensureV1Suffix(url: string): string { + const trimmed = trimTrailingSlashes(url); + return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; +} + +/** + * Default provider prefixes that should use the Anthropic-native SDK. + * Covers all OmniRoute aliases that route to Anthropic upstream. + */ +export const DEFAULT_ANTHROPIC_PREFIXES = [ + "cc", + "claude", + "anthropic", + "kiro", + "kr", +]; + +/** + * Resolve the `api` block for a ModelV2 entry given a model id and the + * active `apiFormat` feature config. + * + * - Models whose prefix (before the first `/`) is in `anthropicPrefixes` → + * `{ id: "anthropic", url: baseURL/v1, npm: "@ai-sdk/anthropic" }` + * + * - All others → + * `{ id: "openai-compatible", url: baseURL/v1, npm: "@ai-sdk/openai-compatible" }` + */ +export function resolveApiBlock( + modelId: string, + baseURL: string, + apiFormat?: { anthropicPrefixes?: string[] }, +): { id: string; url: string; npm: string } { + const prefixes = apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES; + const slash = modelId.indexOf("/"); + const prefix = slash === -1 ? modelId : modelId.slice(0, slash); + const isAnthropic = prefixes.includes(prefix); + return isAnthropic + ? { + id: "anthropic", + url: ensureV1Suffix(baseURL), + npm: "@ai-sdk/anthropic", + } + : { + id: "openai-compatible", + url: ensureV1Suffix(baseURL), + npm: "@ai-sdk/openai-compatible", + }; +} function trimTrailingDashes(value: string): string { let i = value.length; while (i > 0 && value.charCodeAt(i - 1) === 0x2d /* "-" */) i--; @@ -212,13 +294,17 @@ function trimLeadingDashes(value: string): string { * sees a consistent identifier. */ export function resolveOmniRoutePluginOptions( - opts?: OmniRoutePluginOptions -): Required> & + opts?: OmniRoutePluginOptions, +): Required< + Pick +> & Pick { const providerId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY; const displayName = opts?.displayName ?? - (providerId === OMNIROUTE_PROVIDER_KEY ? "OmniRoute" : `OmniRoute (${providerId})`); + (providerId === OMNIROUTE_PROVIDER_KEY + ? "OmniRoute" + : `OmniRoute (${providerId})`); const modelCacheTtl = typeof opts?.modelCacheTtl === "number" && opts.modelCacheTtl > 0 ? opts.modelCacheTtl @@ -249,7 +335,9 @@ export function resolveOmniRoutePluginOptions( * Exported so callers and tests can validate options independent of the * full plugin factory invocation. */ -export function parseOmniRoutePluginOptions(opts: unknown): OmniRoutePluginOptions { +export function parseOmniRoutePluginOptions( + opts: unknown, +): OmniRoutePluginOptions { if (opts === null || opts === undefined) return {}; const result = optionsSchema.safeParse(opts); if (!result.success) { @@ -391,8 +479,11 @@ export function normaliseFreeLabel(name: string): string { * keys by returning `{}` — OC then surfaces the `/connect` flow to the * user instead of dispatching a request with bogus credentials. */ -export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook { - const { providerId, displayName, baseURL, features } = resolveOmniRoutePluginOptions(opts); +export function createOmniRouteAuthHook( + opts?: OmniRoutePluginOptions, +): AuthHook { + const { providerId, displayName, baseURL, features } = + resolveOmniRoutePluginOptions(opts); // Both fetch-layer features default ON (parity with the rest of the plugin's // `features.X !== false` convention). Honoring them here lets users disable // the interceptor/sanitizer from opencode.json — previously these flags were @@ -434,7 +525,8 @@ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook // no `baseURL`. We've already checked the runtime type via typeof so // the unknown-bridge is a safe assertion, not a lie. const authBaseURL = (auth as unknown as { baseURL?: unknown }).baseURL; - const resolvedBaseURL = baseURL ?? (typeof authBaseURL === "string" ? authBaseURL : ""); + const resolvedBaseURL = + baseURL ?? (typeof authBaseURL === "string" ? authBaseURL : ""); // Without a baseURL the interceptor can't tell which requests to // intercept (it would either gate-keep nothing or, worse, all // outbound traffic). Fall back to apiKey-only and let the SDK use @@ -468,8 +560,8 @@ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook ); } return composedFetch - ? { apiKey, baseURL: resolvedBaseURL, fetch: composedFetch } - : { apiKey, baseURL: resolvedBaseURL }; + ? { apiKey, baseURL: sdkBaseURL, fetch: composedFetch } + : { apiKey, baseURL: sdkBaseURL }; } return {}; }, @@ -502,8 +594,30 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => { const sharedCache: OmniRouteFetchCache = new Map(); // Debug breadcrumb: confirm server() invocation + resolved options. // Useful when diagnosing "is the plugin even running" from OC logs. - console.warn( - `[omniroute-plugin] initialized providerId=${resolved.providerId} displayName="${resolved.displayName}" baseURL=${resolved.baseURL ?? "(from auth.json)"} modelCacheTtl=${resolved.modelCacheTtl}ms` + const _ver: string = + ((globalThis as Record).__PLUGIN_VERSION__ as string) ?? + "dev"; + const _hash: string = + ((globalThis as Record).__PLUGIN_GIT_HASH__ as string) ?? + "unknown"; + const _prefixes = + resolved.features?.apiFormat?.anthropicPrefixes ?? + DEFAULT_ANTHROPIC_PREFIXES; + _logger.always( + `v${_ver} (${_hash}) initialized` + + ` providerId=${resolved.providerId}` + + ` baseURL=${resolved.baseURL ?? "(from auth.json)"}` + + ` modelCacheTtl=${resolved.modelCacheTtl}ms` + + ` apiFormat=anthropic:[${_prefixes.join(",")}]` + + ` debugLog=${resolved.features?.debugLog ?? false}` + + ` logLevel=${resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")}`, + ); + + // Wire log level: startupDebug:true → "debug", explicit logLevel wins. + setLogLevel( + resolved.features?.startupDebug + ? "debug" + : (resolved.features?.logLevel ?? "warn"), ); return { auth: createOmniRouteAuthHook(resolved), @@ -583,7 +697,7 @@ export interface OmniRouteRawModelEntry { export type OmniRouteModelsFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number + timeoutMs?: number, ) => Promise; /** @@ -596,15 +710,23 @@ export type OmniRouteModelsFetcher = ( export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( baseURL, apiKey, - timeoutMs = 10_000 + timeoutMs = 10_000, ) => { - if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /v1/models"); - if (!baseURL) throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /v1/models"); + if (!apiKey) + throw new Error( + "@omniroute/opencode-plugin: apiKey required to fetch /v1/models", + ); + if (!baseURL) + throw new Error( + "@omniroute/opencode-plugin: baseURL required to fetch /v1/models", + ); const trimmed = trimTrailingSlashes(baseURL); // Tolerate both `https://host` and `https://host/v1` forms — the gateway // exposes /v1/models either way; we just don't want a double `/v1/v1`. - const url = /\/v\d+$/.test(trimmed) ? `${trimmed}/models` : `${trimmed}/v1/models`; + const url = /\/v\d+$/.test(trimmed) + ? `${trimmed}/models` + : `${trimmed}/v1/models`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -619,18 +741,24 @@ export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( }); if (!res.ok) { throw new Error( - `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}` + `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}`, ); } const body = (await res.json()) as unknown; const rawList: unknown[] = Array.isArray(body) ? body - : body && typeof body === "object" && Array.isArray((body as { data?: unknown }).data) + : body && + typeof body === "object" && + Array.isArray((body as { data?: unknown }).data) ? ((body as { data: unknown[] }).data as unknown[]) : []; const out: OmniRouteRawModelEntry[] = []; for (const r of rawList) { - if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") { + if ( + r && + typeof r === "object" && + typeof (r as { id?: unknown }).id === "string" + ) { out.push(r as OmniRouteRawModelEntry); } } @@ -712,7 +840,7 @@ export function mapRawModelToModelV2( * `/api/pricing/models` data via `applyEnrichment` when * `features.enrichment` is true. */ - name: raw.id, + name: _normaliseFreeLabel(raw.id), capabilities: { temperature: caps.temperature ?? true, reasoning: Boolean(caps.reasoning || caps.thinking), @@ -741,8 +869,11 @@ export function mapRawModelToModelV2( }, limit: { context: typeof raw.context_length === "number" ? raw.context_length : 0, - ...(typeof raw.max_input_tokens === "number" ? { input: raw.max_input_tokens } : {}), - output: typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : 0, + ...(typeof raw.max_input_tokens === "number" + ? { input: raw.max_input_tokens } + : {}), + output: + typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : 0, }, status: "active", options: {}, @@ -817,7 +948,7 @@ export interface OmniRouteRawCombo { export type OmniRouteCombosFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number + timeoutMs?: number, ) => Promise; /** @@ -841,11 +972,16 @@ export type OmniRouteCombosFetcher = ( export const defaultOmniRouteCombosFetcher: OmniRouteCombosFetcher = async ( baseURL, apiKey, - timeoutMs = 10_000 + timeoutMs = 10_000, ) => { - if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /api/combos"); + if (!apiKey) + throw new Error( + "@omniroute/opencode-plugin: apiKey required to fetch /api/combos", + ); if (!baseURL) - throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /api/combos"); + throw new Error( + "@omniroute/opencode-plugin: baseURL required to fetch /api/combos", + ); // Strip trailing slashes, then strip a trailing `/v1` so we land on the // management plane. Models live under `/v1/models`; combos live under @@ -867,18 +1003,24 @@ export const defaultOmniRouteCombosFetcher: OmniRouteCombosFetcher = async ( }); if (!res.ok) { throw new Error( - `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}` + `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}`, ); } const body = (await res.json()) as unknown; const rawList: unknown[] = Array.isArray(body) ? body - : body && typeof body === "object" && Array.isArray((body as { combos?: unknown }).combos) + : body && + typeof body === "object" && + Array.isArray((body as { combos?: unknown }).combos) ? ((body as { combos: unknown[] }).combos as unknown[]) : []; const out: OmniRouteRawCombo[] = []; for (const r of rawList) { - if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") { + if ( + r && + typeof r === "object" && + typeof (r as { id?: unknown }).id === "string" + ) { out.push(r as OmniRouteRawCombo); } } @@ -930,14 +1072,19 @@ export function mapComboToModelV2( combo: OmniRouteRawCombo, members: OmniRouteRawModelEntry[], providerId: string, - baseURL: string + baseURL: string, + apiFormat?: { anthropicPrefixes?: string[] }, ): ModelV2 { // `every` over an empty array returns true (would lie about an empty // combo's capabilities) — short-circuit to all-false when no members. const hasMembers = members.length > 0; - const memberInMods = members.map((m) => new Set(m.input_modalities ?? ["text"])); - const memberOutMods = members.map((m) => new Set(m.output_modalities ?? ["text"])); + const memberInMods = members.map( + (m) => new Set(m.input_modalities ?? ["text"]), + ); + const memberOutMods = members.map( + (m) => new Set(m.output_modalities ?? ["text"]), + ); const modalityAllHave = (sets: Array>, key: string): boolean => hasMembers && sets.every((s) => s.has(key)); @@ -952,18 +1099,26 @@ export function mapComboToModelV2( .map((m) => m.max_input_tokens) .filter((v): v is number => typeof v === "number" && v > 0); - const everyDeclaresInput = hasMembers && inputValues.length === members.length; + const everyDeclaresInput = + hasMembers && inputValues.length === members.length; const capabilities: ModelV2["capabilities"] = { temperature: - hasMembers && members.every((m) => (m.capabilities?.temperature ?? true) !== false), + hasMembers && + members.every((m) => (m.capabilities?.temperature ?? true) !== false), reasoning: hasMembers && - members.every((m) => Boolean(m.capabilities?.reasoning || m.capabilities?.thinking)), + members.every((m) => + Boolean(m.capabilities?.reasoning || m.capabilities?.thinking), + ), attachment: hasMembers && - members.every((m) => Boolean(m.capabilities?.attachment ?? m.capabilities?.vision ?? false)), - toolcall: hasMembers && members.every((m) => Boolean(m.capabilities?.tool_calling ?? false)), + members.every((m) => + Boolean(m.capabilities?.attachment ?? m.capabilities?.vision ?? false), + ), + toolcall: + hasMembers && + members.every((m) => Boolean(m.capabilities?.tool_calling ?? false)), input: { text: modalityAllHave(memberInMods, "text"), audio: modalityAllHave(memberInMods, "audio"), @@ -978,17 +1133,31 @@ export function mapComboToModelV2( video: modalityAllHave(memberOutMods, "video"), pdf: modalityAllHave(memberOutMods, "pdf"), }, - interleaved: hasMembers && members.every((m) => Boolean(m.capabilities?.thinking)), + interleaved: + hasMembers && members.every((m) => Boolean(m.capabilities?.thinking)), }; + // Combos span multiple providers. Use Anthropic format only when ALL + // members resolve to Anthropic — otherwise fall back to OpenAI-compat + // (lowest common denominator that every upstream understands). + const comboApiBlock = (() => { + if (!hasMembers) return resolveApiBlock("", baseURL, apiFormat); + const allAnthropic = members.every( + (m) => resolveApiBlock(m.id, baseURL, apiFormat).id === "anthropic", + ); + return allAnthropic + ? resolveApiBlock(members[0].id, baseURL, apiFormat) + : { + id: "openai-compatible", + url: ensureV1Suffix(baseURL), + npm: "@ai-sdk/openai-compatible", + }; + })(); + return { id: combo.id, providerID: providerId, - api: { - id: "openai-compatible", - url: baseURL, - npm: "@ai-sdk/openai-compatible", - }, + api: comboApiBlock, name: combo.name && combo.name.trim().length > 0 ? combo.name : combo.id, capabilities, cost: { @@ -1008,6 +1177,142 @@ export function mapComboToModelV2( }; } +// ───────────────────────────────────────────────────────────────────────── +// AUTO COMBOS — virtual server-side combos exposed via /api/combos/auto +// ───────────────────────────────────────────────────────────────────────── + +/** + * Raw shape of an auto combo entry as returned by OmniRoute's + * `/api/combos/auto` endpoint. Auto combos are virtual — they self-manage + * provider selection via scoring/bandit exploration at runtime. + */ +export interface OmniRouteRawAutoCombo { + /** Stable id (e.g. "auto", "auto/coding"). */ + id: string; + /** Human-readable name (e.g. "Auto", "Auto Coding"). */ + name: string; + /** Variant key or undefined for the default auto. */ + variant?: AutoVariant; + /** Provider names eligible for this auto combo. */ + candidatePool?: string[]; + /** Number of candidates resolved at fetch time. */ + candidateCount?: number; + /** Whether this auto combo should be hidden from the picker. */ + isHidden?: boolean; + /** Auto-combo configuration. */ + config?: { + auto?: { + candidatePool?: string[]; + explorationRate?: number; + routerStrategy?: string; + }; + }; +} + +/** + * Fetcher contract for `/api/combos/auto`. Returns the list of virtual + * auto combos the server can create. Same DI pattern as other fetchers. + */ +export type OmniRouteAutoCombosFetcher = ( + baseURL: string, + apiKey: string, + timeoutMs?: number, +) => Promise; + +/** + * Default auto combos fetcher: `GET /api/combos/auto`. + * + * Fault-tolerant: returns empty array on 404 (endpoint doesn't exist yet) + * or any non-2xx / network error. Logs a warning in those cases. + */ +export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = + async (baseURL, apiKey, timeoutMs = 5_000) => { + if (!apiKey || !baseURL) return []; + + const trimmed = trimTrailingSlashes(baseURL); + const root = trimmed.replace(/\/v\d+$/, ""); + const url = `${root}/api/combos/auto`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: controller.signal, + }); + // 404 = endpoint not deployed yet — expected during rollout + if (res.status === 404) { + console.warn( + `[omniroute-plugin] /api/combos/auto not available (404) — auto combos disabled`, + ); + return []; + } + if (!res.ok) { + console.warn( + `[omniroute-plugin] /api/combos/auto failed: ${res.status} ${res.statusText} — auto combos disabled`, + ); + return []; + } + const body = (await res.json()) as unknown; + const rawList: unknown[] = Array.isArray(body) + ? body + : body && + typeof body === "object" && + Array.isArray((body as { combos?: unknown }).combos) + ? ((body as { combos: unknown[] }).combos as unknown[]) + : []; + const out: OmniRouteRawAutoCombo[] = []; + for (const r of rawList) { + if ( + r && + typeof r === "object" && + typeof (r as { id?: unknown }).id === "string" + ) { + out.push(r as OmniRouteRawAutoCombo); + } + } + return out; + } catch (err) { + // Network error, timeout, abort — all non-fatal + console.warn( + `[omniroute-plugin] /api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} — auto combos disabled`, + ); + return []; + } finally { + clearTimeout(timer); + } + }; + +/** + * Convert a raw auto combo into a static model entry for the OpenCode picker. + * Auto combos have tool_call=true, reasoning=true by default (they route + * to capable models). Context/output limits are set to 0 since the actual + * limits depend on which provider is selected at runtime. + */ +export function mapAutoComboToStaticEntry( + autoCombo: OmniRouteRawAutoCombo, +): OmniRouteStaticModelEntry { + const variant = autoCombo.variant; + const name = formatAutoComboName(variant, autoCombo.candidateCount); + return { + name, + attachment: false, + reasoning: true, + temperature: true, + tool_call: true, + limit: { context: 0, output: 0 }, + modalities: { + input: ["text"], + output: ["text"], + }, + cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 }, + }; +} + // ───────────────────────────────────────────────────────────────────────── // ENRICHMENT — pull display names + pricing from /api/pricing/models so // the UI doesn't have to render raw model ids. Gated by features.enrichment. @@ -1054,6 +1359,12 @@ export interface OmniRouteEnrichmentEntry { * model id sold through different upstream connections. */ providerDisplayName?: string; + /** Free-model budget type (from freeModelCatalog). */ + freeType?: FreeModelFreeType; + /** Monthly token budget for recurring free models. */ + monthlyTokens?: number; + /** Credit token budget for credit-based free models. */ + creditTokens?: number; } /** Map keyed by full model id (possibly namespaced, e.g. `cc/claude-sonnet-4-6`). */ @@ -1062,7 +1373,7 @@ export type OmniRouteEnrichmentMap = Map; export type OmniRouteEnrichmentFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number + timeoutMs?: number, ) => Promise; /** @@ -1086,138 +1397,321 @@ export type OmniRouteEnrichmentFetcher = ( * the two fetches are independent so one missing source still surfaces the * other. */ -export const defaultOmniRouteEnrichmentFetcher: OmniRouteEnrichmentFetcher = async ( - baseURL, - apiKey, - timeoutMs = 10_000 -) => { - const out: OmniRouteEnrichmentMap = new Map(); - if (!baseURL || !apiKey) return out; - const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, ""); - const headers = { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json", - }; +export const defaultOmniRouteEnrichmentFetcher: OmniRouteEnrichmentFetcher = + async (baseURL, apiKey, timeoutMs = 10_000) => { + const out: OmniRouteEnrichmentMap = new Map(); + if (!baseURL || !apiKey) return out; + const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, ""); + const headers = { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }; - // ── 1. Catalog with nice display names ──────────────────────────────── - const catalogAc = new AbortController(); - const catalogTimer = setTimeout(() => catalogAc.abort(), timeoutMs); - try { - const res = await fetch(`${root}/api/pricing/models`, { - method: "GET", - headers, - signal: catalogAc.signal, - }); - if (res.ok) { - const body = (await res.json()) as unknown; - const providers = - (body as { providers?: Record })?.providers ?? - (body as Record); - if (providers && typeof providers === "object") { - for (const [providerAlias, slot] of Object.entries(providers)) { - if (!slot || typeof slot !== "object") continue; - const models = (slot as { models?: unknown[] }).models; - if (!Array.isArray(models)) continue; - // Canonical id sits at the per-provider top level (e.g. - // `pricing-models.cc.id === 'claude'`). Falls back to the alias - // itself when missing — common case alias===canonical. - const canonicalRaw = (slot as { id?: unknown }).id; - const providerCanonical = - typeof canonicalRaw === "string" && canonicalRaw.length > 0 - ? canonicalRaw - : providerAlias; - // Upstream provider human label (e.g. `Claude`, `Kiro`, - // `GitHub Models`). Optional — falls back to undefined when - // OmniRoute hasn't curated a label for this slot. - const slotNameRaw = (slot as { name?: unknown }).name; - const providerDisplayName = - typeof slotNameRaw === "string" && slotNameRaw.trim().length > 0 - ? slotNameRaw.trim() - : undefined; - for (const m of models) { - if (!m || typeof m !== "object") continue; - const id = (m as { id?: unknown }).id; - if (typeof id !== "string" || id.length === 0) continue; - const name = (m as { name?: unknown }).name; - const entry: OmniRouteEnrichmentEntry = { - providerAlias, - providerCanonical, - }; - if (providerDisplayName) entry.providerDisplayName = providerDisplayName; - if (typeof name === "string" && name.trim().length > 0) entry.name = name; - const namespaced = `${providerAlias}/${id}`; - if (!out.has(namespaced)) out.set(namespaced, entry); - if (!out.has(id)) out.set(id, entry); + // ── 1. Catalog with nice display names ──────────────────────────────── + const catalogAc = new AbortController(); + const catalogTimer = setTimeout(() => catalogAc.abort(), timeoutMs); + try { + const res = await fetch(`${root}/api/pricing/models`, { + method: "GET", + headers, + signal: catalogAc.signal, + }); + if (res.ok) { + const body = (await res.json()) as unknown; + const providers = + (body as { providers?: Record }) + ?.providers ?? (body as Record); + if (providers && typeof providers === "object") { + for (const [providerAlias, slot] of Object.entries(providers)) { + if (!slot || typeof slot !== "object") continue; + const models = (slot as { models?: unknown[] }).models; + if (!Array.isArray(models)) continue; + // Canonical id sits at the per-provider top level (e.g. + // `pricing-models.cc.id === 'claude'`). Falls back to the alias + // itself when missing — common case alias===canonical. + const canonicalRaw = (slot as { id?: unknown }).id; + const providerCanonical = + typeof canonicalRaw === "string" && canonicalRaw.length > 0 + ? canonicalRaw + : providerAlias; + // Upstream provider human label (e.g. `Claude`, `Kiro`, + // `GitHub Models`). Optional — falls back to undefined when + // OmniRoute hasn't curated a label for this slot. + const slotNameRaw = (slot as { name?: unknown }).name; + const providerDisplayName = + typeof slotNameRaw === "string" && slotNameRaw.trim().length > 0 + ? slotNameRaw.trim() + : undefined; + for (const m of models) { + if (!m || typeof m !== "object") continue; + const id = (m as { id?: unknown }).id; + if (typeof id !== "string" || id.length === 0) continue; + const name = (m as { name?: unknown }).name; + const entry: OmniRouteEnrichmentEntry = { + providerAlias, + providerCanonical, + }; + if (providerDisplayName) + entry.providerDisplayName = providerDisplayName; + if (typeof name === "string" && name.trim().length > 0) + entry.name = name; + const namespaced = `${providerAlias}/${id}`; + if (!out.has(namespaced)) out.set(namespaced, entry); + if (!out.has(id)) out.set(id, entry); + } } } } + } catch { + // Soft-fail; keep going to pricing fetch. + } finally { + clearTimeout(catalogTimer); } - } catch { - // Soft-fail; keep going to pricing fetch. - } finally { - clearTimeout(catalogTimer); - } - // ── 2. Pricing values from /api/pricing ─────────────────────────────── - const priceAc = new AbortController(); - const priceTimer = setTimeout(() => priceAc.abort(), timeoutMs); - try { - const res = await fetch(`${root}/api/pricing`, { - method: "GET", - headers, - signal: priceAc.signal, - }); - if (res.ok) { - const body = (await res.json()) as unknown; - if (body && typeof body === "object" && !Array.isArray(body)) { - for (const [providerAlias, slot] of Object.entries(body as Record)) { - if (!slot || typeof slot !== "object" || Array.isArray(slot)) continue; - for (const [modelId, raw] of Object.entries(slot as Record)) { - if (!raw || typeof raw !== "object") continue; - const p = raw as Record; - const parsed: NonNullable = {}; - // OmniRoute `/api/pricing` keys: - // input → cost.input - // output → cost.output - // cached → cost.cache.read (alias: cacheRead) - // cache_creation → cost.cache.write (alias: cacheWrite) - // Tolerate alternative spellings for forward-compat. - if (typeof p.input === "number") parsed.input = p.input; - if (typeof p.output === "number") parsed.output = p.output; - const cacheRead = - typeof p.cached === "number" - ? p.cached - : typeof p.cacheRead === "number" - ? p.cacheRead - : undefined; - if (typeof cacheRead === "number") parsed.cacheRead = cacheRead; - const cacheWrite = - typeof p.cache_creation === "number" - ? p.cache_creation - : typeof p.cacheWrite === "number" - ? p.cacheWrite - : undefined; - if (typeof cacheWrite === "number") parsed.cacheWrite = cacheWrite; - if (Object.keys(parsed).length === 0) continue; - const namespaced = `${providerAlias}/${modelId}`; - const existingNs = out.get(namespaced); - if (existingNs) existingNs.pricing = { ...(existingNs.pricing ?? {}), ...parsed }; - else out.set(namespaced, { pricing: parsed }); - const existingBare = out.get(modelId); - if (existingBare) existingBare.pricing = { ...(existingBare.pricing ?? {}), ...parsed }; - else out.set(modelId, { pricing: parsed }); + // ── 2. Pricing values from /api/pricing ─────────────────────────────── + const priceAc = new AbortController(); + const priceTimer = setTimeout(() => priceAc.abort(), timeoutMs); + try { + const res = await fetch(`${root}/api/pricing`, { + method: "GET", + headers, + signal: priceAc.signal, + }); + if (res.ok) { + const body = (await res.json()) as unknown; + if (body && typeof body === "object" && !Array.isArray(body)) { + for (const [providerAlias, slot] of Object.entries( + body as Record, + )) { + if (!slot || typeof slot !== "object" || Array.isArray(slot)) + continue; + for (const [modelId, raw] of Object.entries( + slot as Record, + )) { + if (!raw || typeof raw !== "object") continue; + const p = raw as Record; + const parsed: NonNullable = + {}; + // OmniRoute `/api/pricing` keys: + // input → cost.input + // output → cost.output + // cached → cost.cache.read (alias: cacheRead) + // cache_creation → cost.cache.write (alias: cacheWrite) + // Tolerate alternative spellings for forward-compat. + if (typeof p.input === "number") parsed.input = p.input; + if (typeof p.output === "number") parsed.output = p.output; + const cacheRead = + typeof p.cached === "number" + ? p.cached + : typeof p.cacheRead === "number" + ? p.cacheRead + : undefined; + if (typeof cacheRead === "number") parsed.cacheRead = cacheRead; + const cacheWrite = + typeof p.cache_creation === "number" + ? p.cache_creation + : typeof p.cacheWrite === "number" + ? p.cacheWrite + : undefined; + if (typeof cacheWrite === "number") + parsed.cacheWrite = cacheWrite; + if (Object.keys(parsed).length === 0) continue; + const namespaced = `${providerAlias}/${modelId}`; + const existingNs = out.get(namespaced); + if (existingNs) + existingNs.pricing = { + ...(existingNs.pricing ?? {}), + ...parsed, + }; + else out.set(namespaced, { pricing: parsed }); + const existingBare = out.get(modelId); + if (existingBare) + existingBare.pricing = { + ...(existingBare.pricing ?? {}), + ...parsed, + }; + else out.set(modelId, { pricing: parsed }); + } } } } + } catch { + // Soft-fail; return whatever names we collected. + } finally { + clearTimeout(priceTimer); } - } catch { - // Soft-fail; return whatever names we collected. - } finally { - clearTimeout(priceTimer); + + // ── 3. Free model budgets from /api/free-tier/summary ────────────────── + // Best-effort fetch: populates freeType/monthlyTokens/creditTokens on + // enrichment entries that match. 404 = endpoint doesn't exist — skip. + // Uses the EXISTING /api/free-tier/summary endpoint (no new server code). + const freeAc = new AbortController(); + const freeTimer = setTimeout(() => freeAc.abort(), timeoutMs); + try { + const res = await fetch(`${root}/api/free-tier/summary`, { + method: "GET", + headers, + signal: freeAc.signal, + }); + if (res.ok) { + const body = (await res.json()) as unknown; + // Response shape: { perModel: FreeModelBudget[], ... } + const perModel: unknown[] = + body && + typeof body === "object" && + Array.isArray((body as { perModel?: unknown }).perModel) + ? ((body as { perModel: unknown[] }).perModel as unknown[]) + : Array.isArray(body) + ? (body as unknown[]) + : []; + let matched = 0; + for (const fm of perModel) { + if (!fm || typeof fm !== "object") continue; + const fmObj = fm as Record; + const provider = + typeof fmObj.provider === "string" ? fmObj.provider : ""; + const modelId = + typeof fmObj.modelId === "string" ? fmObj.modelId : ""; + const freeType = + typeof fmObj.freeType === "string" ? fmObj.freeType : ""; + if (!modelId || !freeType) continue; + const monthlyTokens = + typeof fmObj.monthlyTokens === "number" + ? fmObj.monthlyTokens + : undefined; + const creditTokens = + typeof fmObj.creditTokens === "number" + ? fmObj.creditTokens + : undefined; + // Match against enrichment entries: namespaced, bare, and displayName + const displayName = + typeof fmObj.displayName === "string" ? fmObj.displayName : ""; + const candidates = [ + `${provider}/${modelId}`, + modelId, + ...(displayName ? [displayName] : []), + ]; + for (const key of candidates) { + const entry = out.get(key); + if (entry) { + entry.freeType = freeType as FreeModelFreeType; + if (monthlyTokens !== undefined) + entry.monthlyTokens = monthlyTokens; + if (creditTokens !== undefined) entry.creditTokens = creditTokens; + matched++; + break; + } + } + } + _logger.debug( + `free-tier/summary: ${perModel.length} models returned, ${matched} matched enrichment entries`, + ); + } + } catch { + // Soft-fail; free metadata is optional. + } finally { + clearTimeout(freeTimer); + } + + return out; + }; + +// ── Startup diagnostics writer (file-based) ────────────────────────────── +// OC doesn't capture plugin console.warn in its log file. Write diagnostics +// to a file so they're readable after session starts. Capped at 64KB. +async function writeStartupDiagnostics(params: { + providerId: string; + baseURL: string; + modelCount: number; + comboCount: number; + enrichmentSize: number; + autoComboCount: number; + enrichment: OmniRouteEnrichmentMap; + autoCombos: OmniRouteRawAutoCombo[]; +}): Promise { + const { + providerId, + baseURL, + modelCount, + comboCount, + enrichmentSize, + autoComboCount, + enrichment, + autoCombos, + } = params; + const enriched = [...enrichment.entries()]; + const withName = enriched.filter(([, e]) => e.name); + const withPricing = enriched.filter(([, e]) => e.pricing); + const withFree = enriched.filter(([, e]) => e.freeType); + + const lines: string[] = []; + lines.push(`=== startupDebug ${new Date().toISOString()} ===`); + lines.push(`providerId=${providerId} baseURL=${baseURL}`); + lines.push( + `models=${modelCount} combos=${comboCount} enrichment=${enrichmentSize} autoCombos=${autoComboCount}`, + ); + lines.push( + `enrichment: ${withName.length} with name, ${withPricing.length} with pricing, ${withFree.length} free`, + ); + if (withFree.length > 0) { + lines.push(`free models (${withFree.length}):`); + for (const [k, e] of withFree.slice(0, 10)) { + lines.push( + ` ${k} → name=${e.name ?? "(none)"}, freeType=${e.freeType}, monthly=${e.monthlyTokens ?? 0}, credits=${e.creditTokens ?? 0}`, + ); + } + } else { + lines.push( + `NO free models detected. ` + + (enrichmentSize === 0 + ? "Enrichment map is EMPTY." + : `Enrichment has ${enrichmentSize} entries but none have freeType.`), + ); + } + const sampleNames = enriched + .filter(([, e]) => e.name) + .slice(0, 5) + .map(([k, e]) => ` ${k} → "${e.name}"`); + if (sampleNames.length > 0) { + lines.push(`sample enriched names:`); + lines.push(sampleNames.join("\n")); } + if (autoCombos.length > 0) { + lines.push( + `auto combos: ${autoCombos.length} — ${autoCombos.map((ac) => `${ac.id}(${ac.candidateCount ?? "?"}p)`).join(", ")}`, + ); + } + lines.push(`=== end startupDebug ===\n`); - return out; -}; + const diagnostics = lines.join("\n"); + _logger.debug(diagnostics); + + try { + const diagDir = + process.env.OPENCODE_DATA_DIR ?? + path.join(os.homedir(), ".local/share/opencode"); + const diagPath = path.join( + diagDir, + "plugins", + "omniroute-startup-diagnostics.log", + ); + let existing = ""; + try { + existing = await readFile(diagPath, "utf8"); + } catch { + /* first write */ + } + const KEEP = 65_536; + const combined = existing + diagnostics; + const trimmed = + combined.length > KEEP + ? combined.slice(combined.length - KEEP) + : combined; + await writeFile(diagPath, trimmed, "utf8"); + } catch { + /* best effort */ + } +} /** * Separator used by `applyProviderTag` between the upstream provider @@ -1229,64 +1723,13 @@ export const defaultOmniRouteEnrichmentFetcher: OmniRouteEnrichmentFetcher = asy * group by provider — e.g. `Claude - Claude Opus 4.7`, * `Kiro - Claude Opus 4.7`). */ -export const PROVIDER_TAG_SEPARATOR = " - "; +export const PROVIDER_TAG_SEPARATOR = _PROVIDER_TAG_SEPARATOR; -/** - * Threshold beyond which `providerDisplayName` is abbreviated. Raised - * from 8 → 12 so curated brand casing (`AssemblyAI`, `Antigravity`, - * `Pollinations`, `GEMINI-CLI` curated form) wins over a shouty - * UPPER(alias) fallback for the common case. - */ -const PROVIDER_LABEL_MAX_CHARS = 12; - -/** - * Aliases longer than this get title-case fallback instead of UPPER — - * keeps short-token UPPER (`cc`→`CC`, `ghm`→`GHM`) but tames long - * lowercase aliases (`antigravity`→`Antigravity`). - */ -const ALIAS_UPPER_MAX_CHARS = 5; - -/** - * Title-case a long, lowercase-looking alias (e.g. `antigravity` → - * `Antigravity`) so the prefix doesn't shout when neither - * `providerDisplayName` nor a short alias is available. - */ -function titleCaseAlias(alias: string): string { - if (alias.length === 0) return alias; - return alias.charAt(0).toUpperCase() + alias.slice(1).toLowerCase(); -} - -/** - * Pick the short label for an upstream provider that goes into the - * `
); + const isHealthy = (c: ConnectionRowConnection): boolean => { + const s = c.testStatus; + return c.isActive !== false && (!s || s === "active" || s === "success"); + }; + const STATUS_FILTER_OPTIONS = [ + { value: "all", label: t("filterAll", "All") }, + { value: "active", label: t("filterActive", "Active") }, + { value: "error", label: t("filterError", "Error") }, + { value: "banned", label: t("filterBanned", "Banned") }, + { value: "credits_exhausted", label: t("filterCreditsExhausted", "Credits Exhausted") }, + ]; + const filtered = healthFilter === "all" + ? sorted + : sorted.filter((c) => { + if (healthFilter === "active") return isHealthy(c); + if (healthFilter === "error") return !isHealthy(c) && c.testStatus !== "banned" && c.testStatus !== "credits_exhausted"; + return c.testStatus === healthFilter; + }); + + const totalFilteredPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const clampedPage = Math.min(page, totalFilteredPages - 1); + const pageStart = clampedPage * PAGE_SIZE; + const pageEnd = pageStart + PAGE_SIZE; + + const filterPills = ( +
+ {STATUS_FILTER_OPTIONS.map((opt) => ( + + ))} +
+ ); + + const paginationBar = totalFilteredPages > 1 ? ( +
+ + {pageStart + 1}–{Math.min(pageEnd, filtered.length)} / {filtered.length} + +
+
+
+ ) : null; + if (!hasAnyTag) { + const pageConnections = filtered.slice(pageStart, pageEnd); + const allSelected = pageConnections.length > 0 && pageConnections.every((c) => selectedIds.has(c.id)); + const someSelected = pageConnections.some((c) => selectedIds.has(c.id)); return ( <> -
- +
+
+ + {filterPills} +
{bulkActions}
- {sorted.map((conn, index) => ( + {pageConnections.length === 0 ? ( +
+ {t("noFilteredConnections", "No connections match the current filter.")} +
+ ) : ( + pageConnections.map((conn, index) => ( handleToggleSelectOne(conn.id)} onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])} @@ -4927,15 +5034,17 @@ export default function ProviderDetailPage() { perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)} onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)} /> - ))} + ))) + }
+ {paginationBar} ); } // Build ordered tag groups: untagged first, then alphabetically const groupMap = new Map(); - for (const conn of sorted) { + for (const conn of filtered) { const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || ""; if (!groupMap.has(tag)) groupMap.set(tag, []); groupMap.get(tag)!.push(conn); @@ -4949,35 +5058,38 @@ export default function ProviderDetailPage() { return ( <> {selectedIds.size > 0 || connections.length > 0 ? ( -
- +
+
+ + {filterPills} +
{/* Distribute Proxies lives in the provider toolbar (top action bar); @@ -5313,6 +5425,16 @@ export default function ProviderDetailPage() { onClose={handleCloseAddApiKeyModal} /> )} + setBatchDeleteConfirmOpen(false)} + onConfirm={handleBatchDeleteConfirm} + title={t("batchDeleteConfirmTitle", "Delete connections")} + message={t("batchDeleteConfirm", { count: selectedIds.size })} + confirmText={t("batchDeleteConfirmButton", "Delete")} + cancelText={t("cancel", "Cancel")} + loading={batchDeleting} + /> {providerId === "codex" && applyCodexModalConnectionId && ( { + const keyword = newBannedKeyword.trim().toLowerCase(); + if (!keyword || customBannedSignals.includes(keyword)) return; + updateSetting("customBannedSignals", [...customBannedSignals, keyword]); + setNewBannedKeyword(""); + }; + + const removeBannedKeyword = (index: number) => { + const updated = customBannedSignals.filter((_, i) => i !== index); + updateSetting("customBannedSignals", updated); + }; + const handlePasswordChange = async (e) => { e.preventDefault(); if (passwords.new !== passwords.confirm) { @@ -368,6 +383,59 @@ export default function SecurityTab() { + + {/* Custom Banned Keywords */} + +
+
+

{t("customBannedSignals", "Banned Keywords")}

+

+ {t("customBannedSignalsDesc", "Additional keywords that trigger permanent account ban detection. Built-in keywords always apply.")} +

+
+
+ ) => setNewBannedKeyword(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") addBannedKeyword(); + }} + /> + +
+ {customBannedSignals.length > 0 ? ( +
+ {customBannedSignals.map((keyword, index) => ( +
+ {keyword} + +
+ ))} +
+ ) : ( +

+ {t("noCustomBannedSignals", "No custom keywords. Only built-in keywords are active.")} +

+ )} +
+
); } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index bbdc7fd29220..4c5970ae5b67 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4000,6 +4000,14 @@ "batchRetestSelected": "Retest", "batchActivateSuccess": "Activated {count} connection(s)", "batchDeactivateSuccess": "Deactivated {count} connection(s)", + "batchDeleteConfirmTitle": "Delete connections", + "batchDeleteConfirmButton": "Delete", + "filterAll": "All", + "filterActive": "Active", + "filterError": "Error", + "filterBanned": "Banned", + "filterCreditsExhausted": "Credits Exhausted", + "noFilteredConnections": "No connections match the current filter.", "failedSetAlias": "Failed to set alias", "setAliasSuccess": "Alias {alias} set", "deleteAliasSuccess": "Alias {alias} deleted", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 844e0492e140..068cb9757775 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3966,6 +3966,14 @@ "batchRetestSelected": "__MISSING__:Retest", "batchActivateSuccess": "__MISSING__:Activated {count} connection(s)", "batchDeactivateSuccess": "__MISSING__:Deactivated {count} connection(s)", + "batchDeleteConfirmTitle": "删除连接", + "batchDeleteConfirmButton": "删除", + "filterAll": "全部", + "filterActive": "健康", + "filterError": "错误", + "filterBanned": "封禁", + "filterCreditsExhausted": "额度耗尽", + "noFilteredConnections": "没有符合当前筛选条件的连接。", "failedSetAlias": "设置别名失败", "failedSaveConnection": "保存连接失败", "failedSaveConnectionRetry": "无法保存连接。请再试一次。", diff --git a/src/lib/config/runtimeSettings.ts b/src/lib/config/runtimeSettings.ts index c8ad61ad1755..b589167505d5 100644 --- a/src/lib/config/runtimeSettings.ts +++ b/src/lib/config/runtimeSettings.ts @@ -1,4 +1,5 @@ import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck"; +import { setCustomBannedSignals } from "@omniroute/open-sse/services/accountFallback.ts"; type JsonRecord = Record; @@ -15,7 +16,8 @@ export type RuntimeReloadSection = | "corsOrigins" | "ccBridgeTransforms" | "systemTransforms" - | "authzBypass"; + | "authzBypass" + | "bannedSignals"; export interface RuntimeReloadChange { section: RuntimeReloadSection; @@ -42,6 +44,7 @@ interface RuntimeSettingsSnapshot { ccBridgeTransforms: unknown; systemTransforms: unknown; authzBypass: AuthzBypassSnapshot; + customBannedSignals: string[]; } // Default bypass policy: kill-switch on, `/api/mcp/` bypassable. Mirrors the @@ -67,6 +70,7 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = { ccBridgeTransforms: null, systemTransforms: null, authzBypass: DEFAULT_AUTHZ_BYPASS_SNAPSHOT, + customBannedSignals: [], }; let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null; @@ -246,6 +250,7 @@ export function buildRuntimeSettingsSnapshot( ccBridgeTransforms: parseStoredJson(settings.ccBridgeTransforms, "ccBridgeTransforms"), systemTransforms: parseStoredJson(settings.systemTransforms, "systemTransforms"), authzBypass: normalizeAuthzBypass(settings), + customBannedSignals: normalizeStringArray(settings.customBannedSignals), }; } @@ -526,6 +531,11 @@ export async function applyRuntimeSettings( markChanged("authzBypass"); } + if (force || hasChanged(currentSnapshot.customBannedSignals, previousSnapshot.customBannedSignals)) { + setCustomBannedSignals(currentSnapshot.customBannedSignals); + markChanged("bannedSignals"); + } + lastAppliedSnapshot = currentSnapshot; return changes; } diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 6aab3d1bfd14..42246413ca19 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -123,6 +123,7 @@ export async function getSettings() { // `applyAuthzBypassSection` → `getAuthzBypassSnapshot()`. localOnlyManageScopeBypassEnabled: true, localOnlyManageScopeBypassPrefixes: ["/api/mcp/"], + customBannedSignals: [], proxyEnabled: true, perKeyProxyEnabled: false, }; diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 63aee1fab7fe..88b7b9a9458a 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -42,6 +42,7 @@ export const updateSettingsSchema = z.object({ showProviderTopologyOnHome: z.boolean().optional(), localOnlyManageScopeBypassEnabled: z.boolean().optional(), localOnlyManageScopeBypassPrefixes: z.array(z.string().max(200)).optional(), + customBannedSignals: z.array(z.string().max(200)).optional(), debugMode: z.boolean().optional(), hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(), sidebarSectionOrder: z diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 0797b7f123e2..788508c7a846 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -1186,3 +1186,64 @@ test("G-02: five consecutive 503 service_not_running do NOT trip provider circui ); clearProviderFailure("9router"); // cleanup }); + +// ── Custom banned signals (PR #3454) ────────────────────────────────────────── +// Operators can extend ACCOUNT_DEACTIVATED_SIGNALS with provider-specific +// permanent-ban phrasing via Settings → Security. These persist in the +// key_value settings store and are applied at boot + on hot-reload through +// setCustomBannedSignals(). Regression guard for the merge/detection behavior. + +const { + setCustomBannedSignals, + getMergedBannedSignals, + isAccountDeactivated, + ACCOUNT_DEACTIVATED_SIGNALS, +} = accountFallback; + +test("getMergedBannedSignals returns built-in list unchanged when no custom signals", () => { + setCustomBannedSignals([]); + const merged = getMergedBannedSignals(); + assert.deepEqual(merged, ACCOUNT_DEACTIVATED_SIGNALS); +}); + +test("getMergedBannedSignals appends custom signals to the built-in list", () => { + setCustomBannedSignals(["api key revoked", "tenant suspended"]); + const merged = getMergedBannedSignals(); + // Built-ins still present + for (const sig of ACCOUNT_DEACTIVATED_SIGNALS) { + assert.ok(merged.includes(sig), `built-in signal "${sig}" must survive merge`); + } + // Custom appended + assert.ok(merged.includes("api key revoked")); + assert.ok(merged.includes("tenant suspended")); + setCustomBannedSignals([]); // cleanup +}); + +test("isAccountDeactivated still matches built-in signals when custom list is empty", () => { + setCustomBannedSignals([]); + assert.equal(isAccountDeactivated("Your account has been suspended"), true); + assert.equal(isAccountDeactivated("rate limit exceeded, retry later"), false); +}); + +test("isAccountDeactivated matches a custom signal after setCustomBannedSignals", () => { + setCustomBannedSignals([]); + // Before registration the custom phrase is not a ban signal + assert.equal( + isAccountDeactivated("Error: API key revoked by administrator"), + false, + "custom phrase must not match before it is registered" + ); + + setCustomBannedSignals(["api key revoked"]); + // Case-insensitive substring match against the merged list + assert.equal( + isAccountDeactivated("Error: API key revoked by administrator"), + true, + "custom phrase must match once registered (case-insensitive substring)" + ); + + // Built-ins remain matchable alongside custom signals + assert.equal(isAccountDeactivated("account_deactivated"), true); + + setCustomBannedSignals([]); // cleanup — restore module state for other tests +}); From cc850122e3d98b51d17de489bf56db40af4afd88 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:54:20 -0300 Subject: [PATCH 26/34] fix(translator): strip function_call.id for Vertex AI provider (#3440) (#3457) Vertex AI's FunctionCall/FunctionResponse protos have no id field; emitting it made Vertex reject tool calls with 400 'Unknown name id'. The id is now stripped only when the routed provider is vertex/vertex-partner (threaded via credentials._provider), preserving it for the public Gemini API where Gemini 3+ uses it for signature matching. Co-authored-by: nullbytef0x --- CHANGELOG.md | 4 + open-sse/translator/index.ts | 14 +- .../translator/request/claude-to-gemini.ts | 11 +- .../translator/request/openai-to-gemini.ts | 16 +- .../unit/vertex-functioncall-id-3440.test.ts | 143 ++++++++++++++++++ 5 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 tests/unit/vertex-functioncall-id-3440.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aeb8a4b00d76..9850b852a036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ _Development cycle in progress — entries are added as work merges into `release/v3.8.17` and finalized by the release flow._ +### 🔧 Bug Fixes + +- **fix(translator):** Vertex AI tool calls no longer fail with `400 Unknown name "id" at contents[].parts[].function_call` — the OpenAI-style `id` field is now stripped from `functionCall`/`functionResponse` parts when the routed provider is `vertex`/`vertex-partner`. The public Gemini API still receives `id` (required for Gemini 3+ signature matching). ([#3440](https://github.com/diegosouzapw/OmniRoute/issues/3440) — thanks @nullbytef0x) + --- ## [3.8.16] — 2026-06-08 diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 0b6501da8c41..b4928af32e96 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -175,7 +175,13 @@ export function translateRequest( // Check for direct translation path first (e.g., Claude → Gemini) const directTranslator = getRequestTranslator(sourceFormat, targetFormat); if (directTranslator && sourceFormat !== FORMATS.OPENAI && targetFormat !== FORMATS.OPENAI) { - result = directTranslator(model, result, stream, credentials); + // Thread the routed provider id so target translators can apply provider-specific + // quirks (e.g. Vertex rejects function_call.id — #3440). + const directCredentials = + provider != null + ? { ...(credentials && typeof credentials === "object" ? credentials : {}), _provider: provider } + : credentials; + result = directTranslator(model, result, stream, directCredentials); } else { // Fallback: hub-and-spoke via OpenAI // Step 1: source -> openai (if source is not openai) @@ -205,13 +211,17 @@ export function translateRequest( const hasNs = options?.signatureNamespace != null; const hasPreCompression = options?.preCompressionBody != null; const hasCopilot = options?.copilotClient === true; + const hasProvider = provider != null; const translationCredentials = - hasNs || hasPreCompression || hasCopilot + hasNs || hasPreCompression || hasCopilot || hasProvider ? { ...(credentials && typeof credentials === "object" ? credentials : {}), ...(hasNs ? { _signatureNamespace: options.signatureNamespace } : {}), ...(hasPreCompression ? { _preCompressionBody: options.preCompressionBody } : {}), ...(hasCopilot ? { _copilotClient: true } : {}), + // Routed provider id so target translators can apply provider-specific + // quirks (e.g. Vertex rejects function_call.id — #3440). + ...(hasProvider ? { _provider: provider } : {}), } : credentials; result = fromOpenAI(model, result, stream, translationCredentials); diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index fb3665c790fe..fda442ea61b1 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -14,12 +14,17 @@ import { capMaxOutputTokens } from "../../../src/lib/modelCapabilities.ts"; * Converts Claude Messages API body directly to Gemini format, * skipping the OpenAI hub intermediate step. */ -export function claudeToGeminiRequest(model, body, stream) { +export function claudeToGeminiRequest(model, body, stream, credentials = null) { const toolNameMap = new Map(); const sanitizeToolName = (name: string) => sanitizeGeminiToolName(name, { toolNameMap, }); + // Vertex AI rejects the `id` field inside function_call / function_response parts + // (#3440). The public Gemini API keeps it for Gemini 3+ signature matching, so this + // is scoped to the routed vertex provider only (threaded via credentials._provider). + const provider = credentials && typeof credentials === "object" ? credentials._provider : null; + const stripFunctionCallId = provider === "vertex" || provider === "vertex-partner"; const result: { model: string; contents: Array>; @@ -105,7 +110,7 @@ export function claudeToGeminiRequest(model, body, stream) { case "tool_use": parts.push({ functionCall: { - id: block.id, + ...(stripFunctionCallId ? {} : { id: block.id }), name: sanitizeToolName(block.name), args: block.input || {}, }, @@ -127,7 +132,7 @@ export function claudeToGeminiRequest(model, body, stream) { } parts.push({ functionResponse: { - id: block.tool_use_id, + ...(stripFunctionCallId ? {} : { id: block.tool_use_id }), name: toolUseNames[block.tool_use_id] || "unknown", response: { result: parsedContent }, }, diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index b7a5dd06660b..1f8a2355b013 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -109,8 +109,19 @@ type GeminiToolNameOptions = { functionResponseShape?: "result" | "output"; signatureNamespace?: string | null; signaturelessToolCallMode?: "native" | "text" | "context"; + // Vertex AI's FunctionCall/FunctionResponse protos have no `id` field; emitting it + // makes Vertex reject the request with 400 "Unknown name id" (#3440). The public + // Gemini API DOES use `id` for Gemini 3+ signature matching, so this is scoped to + // the vertex provider only. + stripFunctionCallId?: boolean; }; +// Vertex AI (and Vertex Partner models) reject the OpenAI-style `id` field inside +// function_call / function_response parts. Detect these by the routed provider id. +function isVertexGeminiProvider(provider: unknown): boolean { + return provider === "vertex" || provider === "vertex-partner"; +} + type OpenAIToolCallLike = { thoughtSignature?: unknown; thought_signature?: unknown; @@ -430,7 +441,7 @@ function openaiToGeminiBase( parts.push({ ...(embeddedThoughtSignature ? { thoughtSignature: embeddedThoughtSignature } : {}), functionCall: { - id: id, + ...(toolNameOptions.stripFunctionCallId ? {} : { id: id }), name: sanitizeToolName(fn.name), args: args, }, @@ -482,7 +493,7 @@ function openaiToGeminiBase( toolParts.push({ functionResponse: { - id: fid, + ...(toolNameOptions.stripFunctionCallId ? {} : { id: fid }), name: name, response: toolNameOptions.functionResponseShape === "output" @@ -605,6 +616,7 @@ export function openaiToGeminiRequest( return openaiToGeminiBase(model, body, stream, { signatureNamespace, signaturelessToolCallMode: options.signaturelessToolCallMode, + stripFunctionCallId: isVertexGeminiProvider(credentials?._provider), }); } diff --git a/tests/unit/vertex-functioncall-id-3440.test.ts b/tests/unit/vertex-functioncall-id-3440.test.ts new file mode 100644 index 000000000000..8609755c3660 --- /dev/null +++ b/tests/unit/vertex-functioncall-id-3440.test.ts @@ -0,0 +1,143 @@ +// Regression test for #3440 — Vertex AI rejects tool calls because OmniRoute +// emits an `id` field inside `function_call` / `function_response` parts. +// +// Vertex AI (aiplatform.googleapis.com) follows an older Gemini REST schema whose +// FunctionCall/FunctionResponse protos have no `id` field, so it returns +// `400 INVALID_ARGUMENT: Unknown name "id" at 'contents[].parts[].function_call'`. +// The public Gemini API (generativelanguage.googleapis.com) DOES use `id` for +// Gemini 3+ signature matching, so the strip must be scoped to the vertex provider +// only (threaded via credentials._provider), never applied unconditionally. + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToGeminiRequest } = await import( + "../../open-sse/translator/request/openai-to-gemini.ts" +); +const { claudeToGeminiRequest } = await import( + "../../open-sse/translator/request/claude-to-gemini.ts" +); + +type UnknownRecord = Record; + +function findFunctionCall(result: any): UnknownRecord | undefined { + for (const content of result.contents ?? []) { + for (const part of content.parts ?? []) { + if (part?.functionCall) return part.functionCall as UnknownRecord; + } + } + return undefined; +} + +function findFunctionResponse(result: any): UnknownRecord | undefined { + for (const content of result.contents ?? []) { + for (const part of content.parts ?? []) { + if (part?.functionResponse) return part.functionResponse as UnknownRecord; + } + } + return undefined; +} + +const OPENAI_TOOL_BODY = { + messages: [ + { role: "user", content: "What's the weather?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_weather_1", + type: "function", + function: { name: "get_weather", arguments: '{"city":"Tokyo"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_weather_1", content: '{"temp":20}' }, + ], + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + }, + ], +}; + +const CLAUDE_TOOL_BODY = { + messages: [ + { role: "user", content: "What's the weather?" }, + { + role: "assistant", + content: [ + { type: "tool_use", id: "tu_weather_1", name: "get_weather", input: { city: "Tokyo" } }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "tu_weather_1", content: '{"temp":20}' }], + }, + ], +}; + +test("#3440 OpenAI->Gemini: vertex provider omits id from functionCall and functionResponse", () => { + const result = openaiToGeminiRequest("gemini-2.5-pro", OPENAI_TOOL_BODY, false, { + _provider: "vertex", + }); + + const fc = findFunctionCall(result); + assert.ok(fc, "expected a functionCall part"); + assert.equal(fc.id, undefined, "functionCall.id must be omitted for Vertex"); + assert.equal(fc.name, "get_weather"); + + const fr = findFunctionResponse(result); + assert.ok(fr, "expected a functionResponse part"); + assert.equal(fr.id, undefined, "functionResponse.id must be omitted for Vertex"); +}); + +test("#3440 OpenAI->Gemini: vertex-partner provider also omits id", () => { + const result = openaiToGeminiRequest("gemini-2.5-pro", OPENAI_TOOL_BODY, false, { + _provider: "vertex-partner", + }); + assert.equal(findFunctionCall(result)?.id, undefined); + assert.equal(findFunctionResponse(result)?.id, undefined); +}); + +test("#3440 OpenAI->Gemini: public gemini provider PRESERVES id (Gemini 3+ signature matching)", () => { + const result = openaiToGeminiRequest("gemini-2.5-pro", OPENAI_TOOL_BODY, false, { + _provider: "gemini", + }); + assert.equal( + findFunctionCall(result)?.id, + "call_weather_1", + "functionCall.id must be preserved for the public Gemini API" + ); +}); + +test("#3440 OpenAI->Gemini: no provider hint PRESERVES id (default, non-vertex)", () => { + const result = openaiToGeminiRequest("gemini-2.5-pro", OPENAI_TOOL_BODY, false, null); + assert.equal(findFunctionCall(result)?.id, "call_weather_1"); +}); + +test("#3440 Claude->Gemini: vertex provider omits id from functionCall and functionResponse", () => { + const result = claudeToGeminiRequest("gemini-2.5-pro", CLAUDE_TOOL_BODY, false, { + _provider: "vertex", + }); + assert.equal(findFunctionCall(result)?.id, undefined, "functionCall.id must be omitted for Vertex"); + assert.equal( + findFunctionResponse(result)?.id, + undefined, + "functionResponse.id must be omitted for Vertex" + ); +}); + +test("#3440 Claude->Gemini: no provider hint PRESERVES id (default, non-vertex)", () => { + const result = claudeToGeminiRequest("gemini-2.5-pro", CLAUDE_TOOL_BODY, false); + assert.equal(findFunctionCall(result)?.id, "tu_weather_1"); +}); From ff65652cdf4479253840fc1f788e7bf76fcda2ff Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:59:38 -0300 Subject: [PATCH 27/34] fix(claude): respect client anthropic-beta instead of forcing thinking/effort betas (#3415) (#3458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code -> claude-opus-4-8 turns intermittently died with 'tool call could not be parsed (retry also failed)'. OmniRoute's claude identity cloak rebuilt the anthropic-beta header from scratch and unconditionally forced interleaved-thinking-2025-05-14 (+ advanced-tool-use / effort for heavy agents), even when the client never negotiated them. The forced interleaved-thinking conflicts with tool_choice-forced turns, producing malformed opus tool_use streams (and sibling 400 'Thinking may not be enabled when tool_choice forces tool use'). selectBetaFlags now takes the client's inbound anthropic-beta: when present, thinking/effort betas are only emitted if the client requested them. Opaque clients (no header — the OAuth cloak path) keep the full set unchanged, so existing behavior and the #2454 model-tier gating are preserved. Co-authored-by: Forcerecon --- CHANGELOG.md | 1 + open-sse/executors/base.ts | 6 +- open-sse/executors/claudeIdentity.ts | 45 ++++++++-- .../claude-beta-client-header-3415.test.ts | 90 +++++++++++++++++++ 4 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 tests/unit/claude-beta-client-header-3415.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9850b852a036..81aedb36b9a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ _Development cycle in progress — entries are added as work merges into `releas ### 🔧 Bug Fixes +- **fix(claude):** Claude Code → `claude-opus-4-8` tool calls no longer break with `tool call could not be parsed (retry also failed)` — OmniRoute no longer force-injects `interleaved-thinking` / `advanced-tool-use` / `effort` beta flags the client never negotiated. When the client sends its own `anthropic-beta` header, those betas are only emitted if the client requested them; opaque clients (OAuth identity cloak) keep the full set unchanged. ([#3415](https://github.com/diegosouzapw/OmniRoute/issues/3415) — thanks @Forcerecon) - **fix(translator):** Vertex AI tool calls no longer fail with `400 Unknown name "id" at contents[].parts[].function_call` — the OpenAI-style `id` field is now stripped from `functionCall`/`functionResponse` parts when the routed provider is `vertex`/`vertex-partner`. The public Gemini API still receives `id` (required for Gemini 3+ signature matching). ([#3440](https://github.com/diegosouzapw/OmniRoute/issues/3440) — thanks @nullbytef0x) --- diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 5521615f7c7a..8bda0a1107fc 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1004,10 +1004,14 @@ export class BaseExecutor { // convention; SSE decoding is gated on body.stream). anthropic-beta // is selected per request shape; the full set on a quota probe is // itself a fingerprint. + // Respect the client's negotiated anthropic-beta (real Claude Code) instead + // of force-injecting thinking/effort betas it never requested (#3415). + const clientAnthropicBeta = + clientHeaders?.["anthropic-beta"] ?? clientHeaders?.["Anthropic-Beta"] ?? null; const ccHeaders: Record = { Accept: "application/json", "anthropic-version": "2023-06-01", - "anthropic-beta": selectBetaFlags(tb), + "anthropic-beta": selectBetaFlags(tb, null, clientAnthropicBeta), "anthropic-dangerous-direct-browser-access": "true", "x-app": "cli", "User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`, diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index dc3140b1b759..470fd623133d 100644 --- a/open-sse/executors/claudeIdentity.ts +++ b/open-sse/executors/claudeIdentity.ts @@ -322,9 +322,32 @@ function isContext1mModel(model: unknown): boolean { */ export function selectBetaFlags( body: Record | null | undefined, - model?: string | null + model?: string | null, + // The client's inbound `anthropic-beta` header, when present. Real Claude Code + // clients negotiate their own beta set; forcing thinking/effort betas they never + // requested produced malformed opus tool_use streams (#3415). When this is a + // non-empty string we respect it and do NOT force those betas. For opaque clients + // (no header — the OAuth identity cloak) the full set is retained unchanged. + clientBeta?: string | null ): string { const b = body || {}; + const clientBetaSet = + typeof clientBeta === "string" && clientBeta.trim().length > 0 + ? new Set( + clientBeta + .split(",") + .map((f) => f.trim()) + .filter(Boolean) + ) + : null; + // When the client negotiated its own anthropic-beta, only emit the thinking / heavy + // betas it actually asked for. Opaque clients (clientBetaSet === null) keep them all. + const allowThinking = + clientBetaSet === null || clientBetaSet.has("interleaved-thinking-2025-05-14"); + const allowHeavy = + clientBetaSet === null || + clientBetaSet.has("advanced-tool-use-2025-11-20") || + clientBetaSet.has("effort-2025-11-24"); const hasSystem = !!b.system && (typeof b.system === "string" || (Array.isArray(b.system) && b.system.length > 0)); @@ -345,13 +368,17 @@ export function selectBetaFlags( if (isContext1m) { flags.push("context-1m-2025-08-07", "mid-conversation-system-2026-04-07"); } - flags.push( - "interleaved-thinking-2025-05-14", - "redact-thinking-2026-02-12", - "thinking-token-count-2026-05-13", - "context-management-2025-06-27", - "prompt-caching-scope-2026-01-05" - ); + // Thinking betas: gated on the client header (#3415). interleaved-thinking forces + // interleaved-thinking semantics that conflict with a tool_choice-forced turn, + // producing malformed opus tool_use streams when the client never asked for it. + if (allowThinking) { + flags.push( + "interleaved-thinking-2025-05-14", + "redact-thinking-2026-02-12", + "thinking-token-count-2026-05-13" + ); + } + flags.push("context-management-2025-06-27", "prompt-caching-scope-2026-01-05"); if (hasStructuredOutput || isFullAgent) flags.push("advisor-tool-2026-03-01"); if (hasStructuredOutput && !isFullAgent) flags.push("structured-outputs-2025-12-15"); // extended-cache-ttl is sent for all full-agent shapes (incl. Haiku); the @@ -359,7 +386,7 @@ export function selectBetaFlags( if (isFullAgent) { flags.push("extended-cache-ttl-2025-04-11", "cache-diagnosis-2026-04-07"); } - if (isHeavyAgent) { + if (isHeavyAgent && allowHeavy) { flags.push("advanced-tool-use-2025-11-20", "effort-2025-11-24"); } return flags.join(","); diff --git a/tests/unit/claude-beta-client-header-3415.test.ts b/tests/unit/claude-beta-client-header-3415.test.ts new file mode 100644 index 000000000000..507b02861f18 --- /dev/null +++ b/tests/unit/claude-beta-client-header-3415.test.ts @@ -0,0 +1,90 @@ +// Regression test for #3415 — forced anthropic-beta rewrite corrupts opus tool_use stream. +// +// For Claude Code traffic to claude-opus-4-8 (claude→claude passthrough), OmniRoute +// rebuilt the anthropic-beta header from scratch and UNCONDITIONALLY forced +// interleaved-thinking-2025-05-14 (+ advanced-tool-use / effort for heavy agents), +// even when the client never negotiated them. Anthropic then returned 200 turns with +// malformed/incomplete tool_use.input (and sibling 400 "Thinking may not be enabled +// when tool_choice forces tool use"), so Claude Code aborted with +// "tool call could not be parsed (retry also failed)". +// +// Fix: when the client sends its own anthropic-beta header (real Claude Code), do NOT +// force the thinking / advanced-tool-use / effort betas it did not request. Opaque +// clients (no client header — the OAuth identity cloak) keep the full set unchanged. + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { selectBetaFlags } = await import("../../open-sse/executors/claudeIdentity.ts"); + +function fullAgentBody(model: string) { + return { + model, + system: "You are a coding agent.", + tools: [{ name: "read_file", description: "x", input_schema: { type: "object" } }], + }; +} + +// --- Opaque clients (no client anthropic-beta) keep current behavior (OAuth cloak) --- + +test("#3415 opaque client (no clientBeta) still receives the full forced set", () => { + const flags = selectBetaFlags(fullAgentBody("claude-opus-4-8")); + assert.ok(flags.includes("interleaved-thinking-2025-05-14")); + assert.ok(flags.includes("advanced-tool-use-2025-11-20")); + assert.ok(flags.includes("effort-2025-11-24")); + assert.ok(flags.includes("oauth-2025-04-20")); +}); + +// --- Client negotiated its own anthropic-beta: do NOT force thinking/effort --- + +test("#3415 opus client WITHOUT interleaved-thinking → interleaved-thinking NOT forced", () => { + const flags = selectBetaFlags( + fullAgentBody("claude-opus-4-8"), + null, + "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14" + ); + assert.ok( + !flags.includes("interleaved-thinking-2025-05-14"), + "must NOT force interleaved-thinking when the client did not request it" + ); + assert.ok( + !flags.includes("advanced-tool-use-2025-11-20"), + "must NOT force advanced-tool-use when the client did not request it" + ); + assert.ok( + !flags.includes("effort-2025-11-24"), + "must NOT force effort when the client did not request it" + ); + // Mandatory OAuth cloak flag is still present. + assert.ok(flags.includes("oauth-2025-04-20")); +}); + +test("#3415 opus client WITH interleaved-thinking → interleaved-thinking preserved", () => { + const flags = selectBetaFlags( + fullAgentBody("claude-opus-4-8"), + null, + "oauth-2025-04-20,interleaved-thinking-2025-05-14" + ); + assert.ok( + flags.includes("interleaved-thinking-2025-05-14"), + "must keep interleaved-thinking when the client requested it" + ); +}); + +test("#3415 opus client WITH effort/advanced-tool-use → preserved", () => { + const flags = selectBetaFlags( + fullAgentBody("claude-opus-4-8"), + null, + "oauth-2025-04-20,advanced-tool-use-2025-11-20,effort-2025-11-24" + ); + assert.ok(flags.includes("advanced-tool-use-2025-11-20")); + assert.ok(flags.includes("effort-2025-11-24")); +}); + +test("#3415 empty client anthropic-beta string is treated as a real (minimal) client header", () => { + // A present-but-empty header means the client negotiated nothing — do not force thinking. + const flags = selectBetaFlags(fullAgentBody("claude-opus-4-8"), null, ""); + // Empty string is falsy → treated as opaque (no header), full set retained. + // This documents the boundary: only a NON-empty client header gates the forced flags. + assert.ok(flags.includes("oauth-2025-04-20")); +}); From 4f38167964d7ace9acde07dd1ac8f486de2be852 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:53:22 -0300 Subject: [PATCH 28/34] fix(catalog): surface imported models on no-auth providers in /api/v1/models (#3200) (#3463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom-models loop in getUnifiedModelsResponse gated every model through hasEligibleConnectionForModel(getConnectionsForProvider(...)). no-auth providers (theoldllm, etc.) never create DB connection rows, so that returned [] and the gate dropped every imported/custom model for them — the Playground dropdown showed nothing for imported models while built-in/custom models on auth providers worked. Built-in models survived because they go through providerSupportsModel(), which already has a no-auth bypass (#2798). The custom-model gate now applies the same no-auth bypass, keeping the eligibility check (with parentProviderType) intact for auth providers. Co-authored-by: tjengbudi Co-authored-by: a2belugin --- CHANGELOG.md | 1 + src/app/api/v1/models/catalog.ts | 11 +++ .../unit/noauth-imported-models-3200.test.ts | 83 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 tests/unit/noauth-imported-models-3200.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 81aedb36b9a8..2d5cb50d7663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ _Development cycle in progress — entries are added as work merges into `releas ### 🔧 Bug Fixes +- **fix(catalog):** imported/custom models on no-auth providers (e.g. The Old LLM) now appear in `GET /api/v1/models` and the Playground model dropdown. The custom-model eligibility gate required a DB connection row, which no-auth providers never have, so every imported model for them was silently dropped (built-in models were unaffected). The gate now applies the same no-auth bypass used elsewhere. ([#3200](https://github.com/diegosouzapw/OmniRoute/issues/3200) — thanks @tjengbudi, @a2belugin) - **fix(claude):** Claude Code → `claude-opus-4-8` tool calls no longer break with `tool call could not be parsed (retry also failed)` — OmniRoute no longer force-injects `interleaved-thinking` / `advanced-tool-use` / `effort` beta flags the client never negotiated. When the client sends its own `anthropic-beta` header, those betas are only emitted if the client requested them; opaque clients (OAuth identity cloak) keep the full set unchanged. ([#3415](https://github.com/diegosouzapw/OmniRoute/issues/3415) — thanks @Forcerecon) - **fix(translator):** Vertex AI tool calls no longer fail with `400 Unknown name "id" at contents[].parts[].function_call` — the OpenAI-style `id` field is now stripped from `functionCall`/`functionResponse` parts when the routed provider is `vertex`/`vertex-partner`. The public Gemini API still receives `id` (required for Gemini 3+ signature matching). ([#3440](https://github.com/diegosouzapw/OmniRoute/issues/3440) — thanks @nullbytef0x) diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 17596a0e7c96..541286c94f39 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -1150,7 +1150,18 @@ export async function getUnifiedModelsResponse( if (!modelId) continue; if (model.isHidden === true) continue; if (getModelIsHidden(canonicalProviderId, modelId)) continue; + // noAuth providers (e.g. theoldllm) never create DB connection rows, so the + // eligibility gate would drop every imported/custom model for them (#3200). + // Mirror providerSupportsModel's noAuth bypass (#2798) — keep the gate for + // auth providers (preserving parentProviderType for compatible UUID nodes). + const isNoAuthProvider = Object.values(NOAUTH_PROVIDERS).some( + (p) => + p.id === canonicalProviderId || + p.id === providerId || + ("alias" in p && p.alias === alias) + ); if ( + !isNoAuthProvider && !hasEligibleConnectionForModel( getConnectionsForProvider(alias, canonicalProviderId, providerId, parentProviderType), modelId diff --git a/tests/unit/noauth-imported-models-3200.test.ts b/tests/unit/noauth-imported-models-3200.test.ts new file mode 100644 index 000000000000..02cbcd4a5508 --- /dev/null +++ b/tests/unit/noauth-imported-models-3200.test.ts @@ -0,0 +1,83 @@ +// Regression test for #3200 — imported/custom models on noAuth providers were missing +// from GET /api/v1/models (and therefore from the Playground model dropdown), while +// BUILT-IN and CUSTOM models on regular auth providers showed up fine. +// +// Root cause: the custom-models loop in catalog.ts gated every model through +// hasEligibleConnectionForModel(getConnectionsForProvider(...)). noAuth providers +// (e.g. theoldllm / alias "tllm") have NO DB connection rows, so getConnectionsForProvider +// returns [] and hasEligibleConnectionForModel([]) === false → the model was dropped. +// Built-in models survived because they go through providerSupportsModel(), which has a +// noAuth bypass (#2798). This test asserts an IMPORTED model on a noAuth provider appears. + +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-noauth-imported-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "catalog-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#3200 imported model on a noAuth provider (theoldllm) appears in /api/v1/models", async () => { + // theoldllm is a noAuth provider (alias "tllm") — it never creates a DB connection row. + // Import a model that is NOT a built-in theoldllm model, so its presence is solely due + // to the custom/imported path (the path the bug breaks). + await modelsDb.addCustomModel("theoldllm", "my-imported-model-3200", "My Imported Model", "imported"); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as { data: Array<{ id: string }> }; + const ids = new Set(body.data.map((m) => m.id)); + + assert.equal(response.status, 200); + assert.ok( + ids.has("tllm/my-imported-model-3200"), + "imported model on noAuth provider must appear under its alias prefix" + ); +}); + +test("#3200 custom/imported models on auth providers still appear (no regression)", async () => { + // kiro is an auth provider; with a manual custom model added, the alias-prefixed id + // must still be present (the active-connection eligibility path is unchanged). + // No connection seeded here — kiro custom models require an eligible connection, so + // this guards that the fix does NOT make auth-provider custom models appear without one. + await modelsDb.addCustomModel("kiro", "custom-kiro-3200", "Custom Kiro"); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as { data: Array<{ id: string }> }; + const ids = new Set(body.data.map((m) => m.id)); + + assert.equal(response.status, 200); + // Auth provider with NO active connection → custom model must NOT leak in. + assert.equal( + ids.has("kiro/custom-kiro-3200"), + false, + "auth-provider custom model must stay gated behind an eligible connection" + ); +}); From 49c11f0cea990054ce35377b3ceb2eff5d63b480 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:34:07 -0400 Subject: [PATCH 29/34] fix(browser): avoid bundling optional cloakbrowser import (#3460) Integrated into release/v3.8.17 --- open-sse/services/browserPool.ts | 15 +++++++++-- .../unit/browser-pool-optional-import.test.ts | 27 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 tests/unit/browser-pool-optional-import.test.ts diff --git a/open-sse/services/browserPool.ts b/open-sse/services/browserPool.ts index 9523edc912b7..e72862e8057e 100644 --- a/open-sse/services/browserPool.ts +++ b/open-sse/services/browserPool.ts @@ -78,11 +78,17 @@ const state: PoolState = { cloakLaunchResolved: false, }; +function getCloakbrowserModuleId(): string { + // Keep this computed: cloakbrowser is an optional runtime enhancer, and a literal + // dynamic import with the package name makes Turbopack resolve it during route compilation. + return ["cloak", "browser"].join(""); +} + async function resolveCloakLaunch(): Promise<((opts: unknown) => Promise) | null> { if (state.cloakLaunchResolved) return state.cloakLaunch; state.cloakLaunchResolved = true; try { - const mod = (await import("cloakbrowser")) as unknown as { + const mod = (await import(getCloakbrowserModuleId())) as unknown as { launch?: (opts: unknown) => Promise; }; state.cloakLaunch = mod.launch ?? null; @@ -110,7 +116,12 @@ function evictStaleContexts(): void { const now = Date.now(); for (const [key, pooled] of state.contexts) { if (now - pooled.lastUsed > CONTEXT_TTL_MS) { - console.log("[BrowserPool] Evicted stale context:", key, "(idle", ((now - pooled.lastUsed) / 1000).toFixed(0) + "s)"); + console.log( + "[BrowserPool] Evicted stale context:", + key, + "(idle", + ((now - pooled.lastUsed) / 1000).toFixed(0) + "s)" + ); state.contexts.delete(key); pooled.context.close().catch(() => {}); } diff --git a/tests/unit/browser-pool-optional-import.test.ts b/tests/unit/browser-pool-optional-import.test.ts new file mode 100644 index 000000000000..2e9f68390f89 --- /dev/null +++ b/tests/unit/browser-pool-optional-import.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, "../.."); +const BROWSER_POOL_PATH = path.join(REPO_ROOT, "open-sse/services/browserPool.ts"); + +describe("browserPool optional cloakbrowser import", () => { + it("keeps cloakbrowser out of static dynamic import resolution", () => { + const source = readFileSync(BROWSER_POOL_PATH, "utf8"); + + assert.equal( + /import\(\s*["']cloakbrowser["']\s*\)/.test(source), + false, + "cloakbrowser must remain runtime-optional; static dynamic import triggers Turbopack resolution" + ); + assert.match( + source, + /Turbopack resolve it during route compilation/, + "the computed import rationale should stay documented near the helper" + ); + assert.match(source, /return \["cloak", "browser"\]\.join\(""\);/); + }); +}); From 1cc2313a4f5318f78c4fac3b37d3146cbb97a070 Mon Sep 17 00:00:00 2001 From: Hevener Ancelmo Pereira Date: Tue, 9 Jun 2026 02:35:08 +0000 Subject: [PATCH 30/34] fix(command-code): align CLI version header (#3462) Integrated into release/v3.8.17 --- open-sse/executors/commandCode.ts | 2 +- src/lib/providers/validation.ts | 39 +++++------ tests/unit/command-code-executor.test.ts | 5 +- .../provider-validation-specialty.test.ts | 68 +++++++------------ tests/unit/responses-handler.test.ts | 3 +- 5 files changed, 49 insertions(+), 68 deletions(-) diff --git a/open-sse/executors/commandCode.ts b/open-sse/executors/commandCode.ts index 81c1e046112d..70453c8419d9 100644 --- a/open-sse/executors/commandCode.ts +++ b/open-sse/executors/commandCode.ts @@ -5,7 +5,7 @@ import { BaseExecutor, mergeUpstreamExtraHeaders, type ExecuteInput } from "./ba type JsonRecord = Record; -const COMMAND_CODE_VERSION = "0.24.1"; +export const COMMAND_CODE_VERSION = process.env.COMMAND_CODE_VERSION?.trim() || "0.33.2"; const MAX_COMMAND_CODE_TOKENS = 200_000; const encoder = new TextEncoder(); diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index e2ac5d9b6637..61c1aa1d40fd 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -320,8 +320,7 @@ async function fetchWithProxyFallback( // Only attempt proxy fallback for retryable errors (network / timeout) // and only when the target is not a local / LAN address. const fetchErr = err as SafeOutboundFetchError; - const isNetworkIssue = - fetchErr?.code === "NETWORK_ERROR" || fetchErr?.code === "TIMEOUT"; + const isNetworkIssue = fetchErr?.code === "NETWORK_ERROR" || fetchErr?.code === "TIMEOUT"; const isRetryable = fetchErr?.isRetryable !== false; const isValidTarget = !isLocal && isRetryableProxyTarget(url); @@ -574,6 +573,7 @@ export async function validateCommandCodeProvider({ apiKey, providerSpecificData providerSpecificData?.validationModelId || entry?.models?.find((model) => model.id === "deepseek/deepseek-v4-flash")?.id || "deepseek/deepseek-v4-flash"; + const { COMMAND_CODE_VERSION } = await import("@omniroute/open-sse/executors/commandCode.ts"); return validateDirectChatProvider({ url, @@ -581,7 +581,7 @@ export async function validateCommandCodeProvider({ apiKey, providerSpecificData headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, - "x-command-code-version": "0.24.1", + "x-command-code-version": COMMAND_CODE_VERSION, "x-cli-environment": "external", "x-project-slug": "pi-cc", "x-taste-learning": "false", @@ -2914,8 +2914,7 @@ async function validateGrokWebProvider({ apiKey, providerSpecificData = {} }: an if (isCloudflareChallenge(errorDetail)) { return { valid: false, - error: - "Grok validation blocked by Cloudflare anti-bot. Try a residential IP or proxy.", + error: "Grok validation blocked by Cloudflare anti-bot. Try a residential IP or proxy.", }; } @@ -3410,9 +3409,8 @@ async function validateClaudeWebProvider({ apiKey, providerSpecificData = {} }: return { valid: false, error: "Paste your sessionKey cookie from claude.ai" }; } - const { tlsFetchClaude, TlsClientUnavailableError } = await import( - "@omniroute/open-sse/services/claudeTlsClient.ts" - ); + const { tlsFetchClaude, TlsClientUnavailableError } = + await import("@omniroute/open-sse/services/claudeTlsClient.ts"); let response: { status: number; text: string | null }; try { @@ -3455,7 +3453,8 @@ async function validateClaudeWebProvider({ apiKey, providerSpecificData = {} }: if (response.status === 401 || response.status === 403) { return { valid: false, - error: "Invalid or expired session cookie — re-paste sessionKey from claude.ai DevTools → Cookies", + error: + "Invalid or expired session cookie — re-paste sessionKey from claude.ai DevTools → Cookies", }; } @@ -3502,7 +3501,8 @@ async function validateGeminiWebProvider({ apiKey, providerSpecificData = {} }: if (response.status === 401 || response.status === 403) { return { valid: false, - error: "Invalid or expired __Secure-1PSID cookie — re-paste from gemini.google.com DevTools → Cookies", + error: + "Invalid or expired __Secure-1PSID cookie — re-paste from gemini.google.com DevTools → Cookies", }; } @@ -3555,7 +3555,8 @@ async function validateCopilotWebProvider({ apiKey, providerSpecificData = {} }: if (response.status === 401 || response.status === 403) { return { valid: false, - error: "Invalid or expired access_token — re-paste from copilot.microsoft.com DevTools → Cookies", + error: + "Invalid or expired access_token — re-paste from copilot.microsoft.com DevTools → Cookies", }; } @@ -3774,15 +3775,10 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi * These providers share a POST /chat/completions auth check pattern and differ * only in default baseUrl and test model name. */ - function buildOpengatewayValidator( - defaultBaseUrl: string, - model: string - ) { + function buildOpengatewayValidator(defaultBaseUrl: string, model: string) { return async ({ apiKey, providerSpecificData }: any) => { try { - const baseUrl = normalizeBaseUrl( - providerSpecificData?.baseUrl || defaultBaseUrl - ); + const baseUrl = normalizeBaseUrl(providerSpecificData?.baseUrl || defaultBaseUrl); const chatUrl = `${baseUrl.replace(/\/chat\/completions$/, "")}/chat/completions`; const res = await validationWrite( chatUrl, @@ -3832,8 +3828,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi // The executor routes these tokens to dashscope.aliyuncs.com, so the // validation must test against dashscope, NOT the Cosy PAT endpoint. try { - const dashscopeUrl = - "https://dashscope.aliyuncs.com/compatible-mode/v1/models"; + const dashscopeUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1/models"; const res = await validationRead( dashscopeUrl, { @@ -3994,7 +3989,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi max_tokens: 1, }), }, - isLocal, + isLocal ); if (res.status === 401 || res.status === 403) { return { valid: false, error: "Invalid API key" }; @@ -4067,7 +4062,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi max_tokens: 1, }), }, - isLocal, + isLocal ); if (res.status === 401 || res.status === 403) { return { valid: false, error: "Invalid API key" }; diff --git a/tests/unit/command-code-executor.test.ts b/tests/unit/command-code-executor.test.ts index c866b8f45916..32eaefee402d 100644 --- a/tests/unit/command-code-executor.test.ts +++ b/tests/unit/command-code-executor.test.ts @@ -8,7 +8,8 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-command-c process.env.DATA_DIR = TEST_DATA_DIR; const { REGISTRY, getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts"); -const { CommandCodeExecutor } = await import("../../open-sse/executors/commandCode.ts"); +const { CommandCodeExecutor, COMMAND_CODE_VERSION } = + await import("../../open-sse/executors/commandCode.ts"); const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts"); const core = await import("../../src/lib/db/core.ts"); @@ -117,7 +118,7 @@ test("Command Code executor posts wrapped body and required headers to /alpha/ge assert.equal(calls[0].url, "https://api.commandcode.ai/alpha/generate"); assert.equal(calls[0].init.method, "POST"); assert.equal(headers.Authorization, "Bearer cc_test_key"); - assert.equal(headers["x-command-code-version"], "0.24.1"); + assert.equal(headers["x-command-code-version"], COMMAND_CODE_VERSION); assert.equal(headers["x-cli-environment"], "external"); assert.equal(headers["x-project-slug"], "pi-cc"); assert.equal(headers["x-taste-learning"], "false"); diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index 235326650d3b..e851558a3e14 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -13,6 +13,8 @@ const { __setTlsFetchOverrideForTesting: __setPplxTlsFetchOverride } = const { __setTlsFetchOverrideForTesting: __setGrokTlsFetchOverride } = await import("../../open-sse/services/grokTlsClient.ts"); +const { COMMAND_CODE_VERSION } = await import("../../open-sse/executors/commandCode.ts"); + const originalFetch = globalThis.fetch; test.afterEach(() => { @@ -506,9 +508,7 @@ test("grok-web validator: 403 with credential-rejection body is treated as auth- test("grok-web validator: TLS client unavailable surfaces actionable error", async () => { __setGrokTlsFetchOverride(async () => { - const { TlsClientUnavailableError } = await import( - "../../open-sse/services/grokTlsClient.ts" - ); + const { TlsClientUnavailableError } = await import("../../open-sse/services/grokTlsClient.ts"); throw new TlsClientUnavailableError("native binary not found"); }); @@ -1960,7 +1960,7 @@ test("validateCommandCodeProvider sends Command Code probe URL, headers, and wra assert.equal(calls[0].method, "POST"); assert.equal(calls[0].headers.Authorization, "Bearer cc_test_key"); assert.equal(calls[0].headers["Content-Type"], "application/json"); - assert.equal(calls[0].headers["x-command-code-version"], "0.24.1"); + assert.equal(calls[0].headers["x-command-code-version"], COMMAND_CODE_VERSION); assert.equal(calls[0].headers["x-cli-environment"], "external"); assert.equal(calls[0].headers["x-project-slug"], "pi-cc"); assert.equal(calls[0].headers["x-taste-learning"], "false"); @@ -2003,7 +2003,11 @@ test("validateCommandCodeProvider rejects auth failures and provider outages", a const { __setTlsFetchOverrideForTesting: __setClaudeTlsFetchOverride } = await import("../../open-sse/services/claudeTlsClient.ts"); -function makeClaudeTlsResponse(status: number, body: string, headers: Record = {}): any { +function makeClaudeTlsResponse( + status: number, + body: string, + headers: Record = {} +): any { const h = new Headers(); for (const [k, v] of Object.entries(headers)) h.set(k, v); return { status, ok: status >= 200 && status < 300, headers: h, text: body, body: null }; @@ -2024,7 +2028,10 @@ test("claude-web validator: 200 from /api/organizations → valid", async () => assert.equal(result.valid, true); assert.equal(result.error, null); assert.equal(captured?.url, "https://claude.ai/api/organizations"); - assert.match((captured?.opts.headers as Record).Cookie || "", /sessionKey=sk-ant-sid02-test-session-key/); + assert.match( + (captured?.opts.headers as Record).Cookie || "", + /sessionKey=sk-ant-sid02-test-session-key/ + ); __setClaudeTlsFetchOverride(null); }); @@ -2072,9 +2079,7 @@ test("claude-web validator: 429 → valid (rate limited means auth passed)", asy }); test("claude-web validator: 500 → Claude.ai unavailable", async () => { - __setClaudeTlsFetchOverride(async () => - makeClaudeTlsResponse(500, "internal server error") - ); + __setClaudeTlsFetchOverride(async () => makeClaudeTlsResponse(500, "internal server error")); const result = await validateProviderApiKey({ provider: "claude-web", @@ -2335,10 +2340,7 @@ test("gitlawb validator: accepts valid API key via chat/completions probe", asyn test("gitlawb validator: 400/422/429 treated as auth success", async () => { for (const status of [400, 422, 429]) { globalThis.fetch = async (url) => { - assert.equal( - String(url), - "https://opengateway.gitlawb.com/v1/xiaomi-mimo/chat/completions" - ); + assert.equal(String(url), "https://opengateway.gitlawb.com/v1/xiaomi-mimo/chat/completions"); return new Response(JSON.stringify({ error: "bad request" }), { status }); }; const result = await validateProviderApiKey({ provider: "gitlawb", apiKey: "glb-key" }); @@ -2349,10 +2351,7 @@ test("gitlawb validator: 400/422/429 treated as auth success", async () => { test("gitlawb validator: rejects invalid API key (401)", async () => { globalThis.fetch = async (url) => { - assert.equal( - String(url), - "https://opengateway.gitlawb.com/v1/xiaomi-mimo/chat/completions" - ); + assert.equal(String(url), "https://opengateway.gitlawb.com/v1/xiaomi-mimo/chat/completions"); return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }); }; @@ -2363,10 +2362,7 @@ test("gitlawb validator: rejects invalid API key (401)", async () => { test("gitlawb validator: rejects invalid API key (403)", async () => { globalThis.fetch = async (url) => { - assert.equal( - String(url), - "https://opengateway.gitlawb.com/v1/xiaomi-mimo/chat/completions" - ); + assert.equal(String(url), "https://opengateway.gitlawb.com/v1/xiaomi-mimo/chat/completions"); return new Response(JSON.stringify({ error: "forbidden" }), { status: 403 }); }; @@ -2387,10 +2383,7 @@ test("gitlawb validator: surfaces network failures", async () => { test("gitlawb validator: accepts custom baseUrl override", async () => { globalThis.fetch = async (url, init = {}) => { - assert.equal( - String(url), - "https://custom-gateway.example.com/v1/xiaomi-mimo/chat/completions" - ); + assert.equal(String(url), "https://custom-gateway.example.com/v1/xiaomi-mimo/chat/completions"); assert.equal((init.headers as Record).Authorization, "Bearer glb-key"); return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { status: 200, @@ -2414,7 +2407,10 @@ test("gitlawb-gmi validator: accepts valid API key via chat/completions probe", globalThis.fetch = async (url, init = {}) => { calls.push({ url: String(url), headers: init.headers || {} }); assert.equal(String(url), "https://opengateway.gitlawb.com/v1/gmi-cloud/chat/completions"); - assert.equal((init.headers as Record).Authorization, "Bearer glb-gmi-valid-key"); + assert.equal( + (init.headers as Record).Authorization, + "Bearer glb-gmi-valid-key" + ); const body = JSON.parse(String(init.body)); assert.equal(body.model, "XiaomiMiMo/MiMo-V2.5-Pro"); assert.equal(body.messages[0].content, "test"); @@ -2435,10 +2431,7 @@ test("gitlawb-gmi validator: accepts valid API key via chat/completions probe", test("gitlawb-gmi validator: accepts 400/422/429 as auth success", async () => { for (const status of [400, 422, 429]) { globalThis.fetch = async (url) => { - assert.equal( - String(url), - "https://opengateway.gitlawb.com/v1/gmi-cloud/chat/completions" - ); + assert.equal(String(url), "https://opengateway.gitlawb.com/v1/gmi-cloud/chat/completions"); return new Response(JSON.stringify({ error: "bad request" }), { status }); }; const result = await validateProviderApiKey({ @@ -2451,10 +2444,7 @@ test("gitlawb-gmi validator: accepts 400/422/429 as auth success", async () => { test("gitlawb-gmi validator: rejects invalid API key (401)", async () => { globalThis.fetch = async (url) => { - assert.equal( - String(url), - "https://opengateway.gitlawb.com/v1/gmi-cloud/chat/completions" - ); + assert.equal(String(url), "https://opengateway.gitlawb.com/v1/gmi-cloud/chat/completions"); return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }); }; @@ -2468,10 +2458,7 @@ test("gitlawb-gmi validator: rejects invalid API key (401)", async () => { test("gitlawb-gmi validator: rejects invalid API key (403)", async () => { globalThis.fetch = async (url) => { - assert.equal( - String(url), - "https://opengateway.gitlawb.com/v1/gmi-cloud/chat/completions" - ); + assert.equal(String(url), "https://opengateway.gitlawb.com/v1/gmi-cloud/chat/completions"); return new Response(JSON.stringify({ error: "forbidden" }), { status: 403 }); }; @@ -2498,10 +2485,7 @@ test("gitlawb-gmi validator: surfaces network failures", async () => { test("gitlawb-gmi validator: accepts custom baseUrl override", async () => { globalThis.fetch = async (url, init = {}) => { - assert.equal( - String(url), - "https://custom-gateway.example.com/v1/gmi-cloud/chat/completions" - ); + assert.equal(String(url), "https://custom-gateway.example.com/v1/gmi-cloud/chat/completions"); assert.equal((init.headers as Record).Authorization, "Bearer glb-gmi-key"); return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { status: 200, diff --git a/tests/unit/responses-handler.test.ts b/tests/unit/responses-handler.test.ts index cdcd6676da9f..df5f40278bc7 100644 --- a/tests/unit/responses-handler.test.ts +++ b/tests/unit/responses-handler.test.ts @@ -9,6 +9,7 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const { handleResponsesCore } = await import("../../open-sse/handlers/responsesHandler.ts"); +const { COMMAND_CODE_VERSION } = await import("../../open-sse/executors/commandCode.ts"); const originalFetch = globalThis.fetch; @@ -253,7 +254,7 @@ test("handleResponsesCore transforms Command Code executor SSE through Responses assert.equal(result.success, true); assert.equal(call.url, "https://api.commandcode.ai/alpha/generate"); assert.equal(call.headers.Authorization, "Bearer cc_test_key"); - assert.equal(call.headers["x-command-code-version"], "0.24.1"); + assert.equal(call.headers["x-command-code-version"], COMMAND_CODE_VERSION); assert.equal(call.body.params.model, "gpt-5.4-mini"); assert.equal(call.body.params.stream, true); From 5c3545b0453398c08a62160aae5a8ea2520d256d Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:36:26 -0400 Subject: [PATCH 31/34] Add Endpoint Token Saver visibility setting (#3461) Integrated into release/v3.8.17 --- .../dashboard/endpoint/EndpointPageClient.tsx | 5 +- .../__tests__/EndpointPageClient.test.tsx | 73 ++++++++++++++++++- .../settings/components/AppearanceTab.tsx | 31 +++++++- .../components/proxy/GlobalConfigTab.tsx | 33 +++++---- src/i18n/messages/ar.json | 12 +++ src/i18n/messages/az.json | 12 +++ src/i18n/messages/bg.json | 12 +++ src/i18n/messages/bn.json | 12 +++ src/i18n/messages/cs.json | 12 +++ src/i18n/messages/da.json | 12 +++ src/i18n/messages/de.json | 12 +++ src/i18n/messages/en.json | 17 ++++- src/i18n/messages/es.json | 12 +++ src/i18n/messages/fa.json | 12 +++ src/i18n/messages/fi.json | 12 +++ src/i18n/messages/fr.json | 12 +++ src/i18n/messages/gu.json | 12 +++ src/i18n/messages/he.json | 12 +++ src/i18n/messages/hi.json | 12 +++ src/i18n/messages/hu.json | 12 +++ src/i18n/messages/id.json | 12 +++ src/i18n/messages/in.json | 12 +++ src/i18n/messages/it.json | 12 +++ src/i18n/messages/ja.json | 12 +++ src/i18n/messages/ko.json | 12 +++ src/i18n/messages/mr.json | 12 +++ src/i18n/messages/ms.json | 12 +++ src/i18n/messages/nl.json | 12 +++ src/i18n/messages/no.json | 12 +++ src/i18n/messages/phi.json | 12 +++ src/i18n/messages/pl.json | 12 +++ src/i18n/messages/pt-BR.json | 28 +++++-- src/i18n/messages/pt.json | 12 +++ src/i18n/messages/ro.json | 12 +++ src/i18n/messages/ru.json | 28 +++++-- src/i18n/messages/sk.json | 12 +++ src/i18n/messages/sv.json | 12 +++ src/i18n/messages/sw.json | 12 +++ src/i18n/messages/ta.json | 12 +++ src/i18n/messages/te.json | 12 +++ src/i18n/messages/th.json | 12 +++ src/i18n/messages/tr.json | 12 +++ src/i18n/messages/uk-UA.json | 12 +++ src/i18n/messages/ur.json | 12 +++ src/i18n/messages/vi.json | 12 +++ src/i18n/messages/zh-CN.json | 28 +++++-- src/shared/constants/homeWidgets.ts | 3 +- src/shared/validation/schemas.ts | 1 + src/shared/validation/settingsSchemas.ts | 1 + src/types/settings.ts | 1 + ...appearance-widget-settings-schema.test.ts} | 10 ++- 51 files changed, 669 insertions(+), 46 deletions(-) rename tests/unit/{home-page-pin-settings-schema.test.ts => appearance-widget-settings-schema.test.ts} (81%) diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 4feb02b230f4..2f12bc7c6165 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -7,6 +7,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useDisplayBaseUrl } from "@/shared/hooks"; import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers"; import { getProviderDisplayName } from "@/lib/display/names"; +import { SHOW_TOKEN_SAVER_ON_ENDPOINT_KEY } from "@/shared/constants/homeWidgets"; import { useTranslations } from "next-intl"; import A2ADashboardPage from "./components/A2ADashboard"; import McpDashboardPage from "./components/MCPDashboard"; @@ -186,6 +187,7 @@ export default function APIPageClient({ machineId }: Readonly(null); const [ngrokToken, setNgrokToken] = useState(""); const [showNgrokTunnel, setShowNgrokTunnel] = useState(true); + const [showTokenSaverOnEndpoint, setShowTokenSaverOnEndpoint] = useState(true); const [expandedTunnel, setExpandedTunnel] = useState(null); const [lanUrls, setLanUrls] = useState([]); const [tailscaleIpUrl, setTailscaleIpUrl] = useState(null); @@ -496,6 +498,7 @@ export default function APIPageClient({ machineId }: Readonly - + {showTokenSaverOnEndpoint && }
diff --git a/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx b/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx index acb0ccd5f544..91308a88df6f 100644 --- a/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx @@ -33,6 +33,23 @@ function getRequestPath(input: RequestInfo | URL) { const cleanupCallbacks: Array<() => void> = []; +function installLocalStorageStub() { + const store = new Map(); + + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + store.set(key, value); + }), + removeItem: vi.fn((key: string) => { + store.delete(key); + }), + clear: vi.fn(() => { + store.clear(); + }), + }); +} + async function waitForText(text: string) { const startedAt = Date.now(); while (!document.body.textContent?.includes(text)) { @@ -119,10 +136,14 @@ vi.mock("next-intl", () => ({ "endpoint.categoryMedia": "Media APIs", "endpoint.categoryUtility": "Utility APIs", "endpoint.vscodeAliasTitle": "VS Code Token Alias", - "endpoint.vscodeAliasDescriptionReady": "Ready-to-paste compatibility URLs using the /api/v1/vscode/{token}/... endpoint.", - "endpoint.vscodeAliasDescriptionError": "Showing placeholder URLs because CLI keys could not be loaded in this session.", - "endpoint.vscodeAliasDescriptionLoading": "Loading CLI keys. Placeholder URLs are shown until a key is available.", - "endpoint.vscodeAliasDescriptionPlaceholder": "Showing placeholder URLs. Create or activate an API key in CLI Tools to replace {token}.", + "endpoint.vscodeAliasDescriptionReady": + "Ready-to-paste compatibility URLs using the /api/v1/vscode/{token}/... endpoint.", + "endpoint.vscodeAliasDescriptionError": + "Showing placeholder URLs because CLI keys could not be loaded in this session.", + "endpoint.vscodeAliasDescriptionLoading": + "Loading CLI keys. Placeholder URLs are shown until a key is available.", + "endpoint.vscodeAliasDescriptionPlaceholder": + "Showing placeholder URLs. Create or activate an API key in CLI Tools to replace {token}.", "endpoint.vscodeAliasManage": "CLI Tools", "endpoint.vscodeAliasBaseLabel": "VS Code base", "endpoint.vscodeAliasModelsLabel": "VS Code models", @@ -157,6 +178,7 @@ describe("EndpointPageClient", () => { globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; vi.stubGlobal("fetch", fetchMock); + installLocalStorageStub(); }); afterEach(() => { @@ -286,4 +308,47 @@ describe("EndpointPageClient", () => { expect(requestPaths.length).toBeGreaterThan(0); expect(requestPaths.every((path) => path === "/api/settings")).toBe(true); }); + + it("hides the Token Saver card when endpoint visibility is disabled", async () => { + fetchMock.mockImplementation((input: RequestInfo | URL) => { + const path = getRequestPath(input); + if (path === "/api/settings") { + return Promise.resolve( + jsonResponse({ + cloudEnabled: false, + cloudConfigured: false, + hideEndpointCloudflaredTunnel: true, + hideEndpointTailscaleFunnel: true, + hideEndpointNgrokTunnel: true, + showTokenSaverOnEndpoint: false, + }) + ); + } + if (path === "/v1/models") { + return Promise.resolve(jsonResponse({ data: [] })); + } + if (path === "/api/mcp/status") { + return Promise.resolve(jsonResponse({ online: false })); + } + if (path === "/api/a2a/status") { + return Promise.resolve(jsonResponse({ status: "ok", tasks: { activeStreams: 0 } })); + } + if (path === "/api/search/providers") { + return Promise.resolve(jsonResponse({ providers: [] })); + } + if (path === "/api/cli-tools/keys") { + return Promise.resolve(jsonResponse({ keys: [] })); + } + throw new Error(`Unexpected request: ${path}`); + }); + + renderEndpointPage(); + + await waitForText("Endpoint"); + await waitForText("0 models across"); + + const requestPaths = fetchMock.mock.calls.map(([input]) => getRequestPath(input)); + expect(document.body.textContent).not.toContain("Token Saver"); + expect(requestPaths).not.toContain("/api/settings/compression"); + }); }); diff --git a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx index 5c41be21b8f8..f099c6333d75 100644 --- a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx @@ -13,7 +13,10 @@ import { normalizeComboConfigMode, type ComboConfigMode, } from "@/shared/constants/comboConfigMode"; -import { PIN_PROVIDER_QUOTA_TO_HOME_KEY } from "@/shared/constants/homeWidgets"; +import { + PIN_PROVIDER_QUOTA_TO_HOME_KEY, + SHOW_TOKEN_SAVER_ON_ENDPOINT_KEY, +} from "@/shared/constants/homeWidgets"; export default function AppearanceTab() { const { theme, setTheme, isDark } = useTheme(); @@ -38,8 +41,11 @@ export default function AppearanceTab() { const pinProviderQuotaToHome = settings.pinProviderQuotaToHome === true; const showQuickStartOnHome = settings.showQuickStartOnHome !== false; const showProviderTopologyOnHome = settings.showProviderTopologyOnHome !== false; + const showTokenSaverOnEndpoint = settings[SHOW_TOKEN_SAVER_ON_ENDPOINT_KEY] !== false; const autoRefreshProviderQuota = settings.autoRefreshProviderQuota === true; - const autoRefreshProviderQuotaInterval = Number.isFinite(settings.autoRefreshProviderQuotaInterval) + const autoRefreshProviderQuotaInterval = Number.isFinite( + settings.autoRefreshProviderQuotaInterval + ) ? Number(settings.autoRefreshProviderQuotaInterval) : 180; const comboConfigMode = normalizeComboConfigMode(settings[COMBO_CONFIG_MODE_SETTING_KEY]); @@ -254,6 +260,27 @@ export default function AppearanceTab() { disabled={loading} />
+ +
+
+

+ {getSettingsLabel("endpointTokenSaver", "Token Saver")} +

+

+ {getSettingsLabel( + "endpointTokenSaverDesc", + "Show the Token Saver panel on the Endpoint page." + )} +

+
+ { + await updateSetting(SHOW_TOKEN_SAVER_ON_ENDPOINT_KEY, checked); + }} + disabled={loading} + /> +
diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx index a588dce7cbb8..7197cccad92c 100644 --- a/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx @@ -147,7 +147,10 @@ export default function GlobalConfigTab() {
-