diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a8a5146b66..845e374433 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,7 +13,7 @@ body: attributes: label: OmniRoute Version description: "Run `omniroute --version` or check the left sidebar in the dashboard." - placeholder: "e.g. 3.0.9" + placeholder: "e.g. 3.7.9" validations: required: true @@ -44,7 +44,7 @@ body: id: os-version attributes: label: OS Version - placeholder: "e.g. Windows 11 23H2, macOS 15.3, Ubuntu 24.04" + placeholder: "e.g. Windows 11 25H2, macOS 26.5, Ubuntu 26.04" validations: required: false @@ -53,7 +53,7 @@ body: attributes: label: Node.js Version description: "Run `node --version`. Skip if using Docker." - placeholder: "e.g. 22.12.0" + placeholder: "e.g. 24.15.0" validations: required: false @@ -70,7 +70,7 @@ body: id: model attributes: label: Model(s) Involved - placeholder: "e.g. claude-sonnet-4-20250514, gpt-4o, gemini-2.5-pro" + placeholder: "e.g. claude-opus-4-7, gpt-5.5, gemini-3.1-pro" validations: required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 56398286db..b69e9ff215 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -27,4 +27,4 @@ ## Reviewer Notes -- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about. +- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about. \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b65ba222eb..4b515d40f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ permissions: env: CI_NODE_VERSION: "24" CI_NODE_24_VERSION: "24" + CI_NODE_26_VERSION: "26" jobs: lint: @@ -206,6 +207,30 @@ jobs: - run: npm run build - run: node --import tsx/esm --test --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts + node-26-compat: + name: Node 26 Compatibility (${{ matrix.shard }}/2) + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: build + strategy: + fail-fast: false + matrix: + shard: [1, 2] + env: + JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-test-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.CI_NODE_26_VERSION }} + cache: npm + - run: npm ci + - run: npm run check:node-runtime + - run: npm run build + - run: node --import tsx/esm --test --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts + test-coverage: name: Coverage runs-on: ubuntu-latest @@ -441,6 +466,7 @@ jobs: - electron-package-smoke - test-unit - node-24-compat + - node-26-compat - test-coverage - sonarqube - coverage-pr-comment @@ -494,6 +520,8 @@ jobs: echo "| Suite | Status |" >> "$GITHUB_STEP_SUMMARY" echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY" echo "| Unit | $(status '${{ needs.test-unit.result }}') |" >> "$GITHUB_STEP_SUMMARY" + echo "| Node 24 Compatibility | $(status '${{ needs.node-24-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY" + echo "| Node 26 Compatibility | $(status '${{ needs.node-26-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| Coverage | $(status '${{ needs.test-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| PR Coverage Comment | $(status '${{ needs.coverage-pr-comment.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index d794d02d81..db89bddd94 100644 --- a/README.md +++ b/README.md @@ -1519,3 +1519,4 @@ MIT License - see [LICENSE](LICENSE) for details. omniroute.online + diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index b02226a8fd..33efd7cf3c 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1121,10 +1121,11 @@ export const REGISTRY: Record = { models: [ { id: "gpt-5.5-pro", name: "GPT-5.5 Pro" }, //pro tier only { id: "gpt-5.5-thinking", name: "GPT-5.5 Thinking" }, //plus, pro tier + { id: "gpt-5.5", name: "GPT-5.5 Instant" }, //free, plus, pro tier { id: "gpt-5.4-pro", name: "GPT-5.4 Pro" }, //pro tier only { id: "gpt-5.4-thinking", name: "GPT-5.4 Thinking" }, //plus, pro tier { id: "gpt-5.4-thinking-mini", name: "GPT-5.4 Thinking Mini" }, //free-login only - { id: "gpt-5.3", name: "GPT-5.3" }, //free, free-login, plus, pro tier + { id: "gpt-5.3", name: "GPT-5.3 Instant" }, //free, free-login, plus, pro tier { id: "gpt-5.3-mini", name: "GPT-5.3 Mini" }, //limit fallback { id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, //pro tier only { id: "gpt-5.2-thinking", name: "GPT-5.2 Thinking" }, //plus ~ tier diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 3b1eb5a871..a7dbae5b6d 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -453,7 +453,10 @@ export function shouldMarkAccountExhaustedFrom429( model: string | null | undefined = null, connectionPassthroughModels?: boolean ): boolean { - return !hasPerModelQuota(provider, model, connectionPassthroughModels); + return ( + shouldPreserveQuotaSignalsFor429(provider) && + !hasPerModelQuota(provider, model, connectionPassthroughModels) + ); } /** diff --git a/open-sse/services/cloudCodeThinking.ts b/open-sse/services/cloudCodeThinking.ts index 9d3b3b944a..96460d7b48 100644 --- a/open-sse/services/cloudCodeThinking.ts +++ b/open-sse/services/cloudCodeThinking.ts @@ -54,6 +54,10 @@ export function stripCloudCodeThinkingConfig( if (isRecord(next.request)) { const request = { ...next.request }; + delete request.reasoning_effort; + delete request.reasoning; + delete request.thinking; + if ("generationConfig" in request) { request.generationConfig = stripGeminiThinkingConfig(request.generationConfig); } diff --git a/package-lock.json b/package-lock.json index bd43b299d5..9583daaead 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", "lucide-react": "^1.14.0", -< "marked": "^18.0.3", + "marked": "^18.0.3", "mermaid": "^11.14.0", "monaco-editor": "^0.55.1", "next": "^16.2.3", @@ -96,38 +96,12 @@ "wtfnode": "^0.10.1" }, "engines": { - "node": ">=20.20.2 <21 || >=22.22.2 <23 || >=24.0.0 <25" + "node": ">=20.20.2 <21 || >=22.22.2 <23 || >=24.0.0 <27" }, "optionalDependencies": { "keytar": "^7.9.0" } }, - "../omniroute-docs-site": { - "name": "docs-site", - "version": "0.1.0", - "extraneous": true, - "dependencies": { - "@mdx-js/loader": "^3.1.1", - "@mdx-js/react": "^3.1.1", - "@next/mdx": "^16.2.4", - "fuse.js": "^7.3.0", - "next": "16.2.4", - "react": "19.2.4", - "react-dom": "19.2.4" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "eslint": "^9", - "eslint-config-next": "16.2.4", - "glob": "^13.0.6", - "gray-matter": "^4.0.3", - "tailwindcss": "^4", - "typescript": "^5" - } - }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -673,21 +647,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -695,9 +669,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -1737,6 +1711,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1753,6 +1730,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1769,6 +1749,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1785,6 +1768,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1801,6 +1787,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1817,6 +1806,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1833,6 +1825,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1849,6 +1844,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1865,6 +1863,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1887,6 +1888,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1909,6 +1913,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1931,6 +1938,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1953,6 +1963,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1975,6 +1988,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1997,6 +2013,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2019,6 +2038,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2269,16 +2291,22 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@next/env": { @@ -2336,6 +2364,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2352,6 +2383,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2368,6 +2402,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2384,6 +2421,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2549,6 +2589,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2565,6 +2608,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2581,6 +2627,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2597,6 +2646,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2838,6 +2890,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2858,6 +2913,9 @@ "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2878,6 +2936,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2898,6 +2959,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2918,6 +2982,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2938,6 +3005,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3326,6 +3396,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3343,6 +3416,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3360,6 +3436,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3377,6 +3456,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3394,6 +3476,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3411,6 +3496,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3454,25 +3542,6 @@ "node": ">=14.0.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", @@ -3634,6 +3703,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3650,6 +3722,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3666,6 +3741,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3682,6 +3760,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3698,6 +3779,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3714,6 +3798,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3928,6 +4015,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3945,6 +4035,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3962,6 +4055,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3979,6 +4075,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4018,70 +4117,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", @@ -4186,9 +4221,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -5044,6 +5079,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5058,6 +5096,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5072,6 +5113,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5086,6 +5130,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5100,6 +5147,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5114,6 +5164,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5128,6 +5181,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5142,6 +5198,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5165,6 +5224,19 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", @@ -10440,10 +10512,6 @@ "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", -<<<<<<< HEAD -======= - "dev": true, ->>>>>>> upstream/release/v3.7.9 "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^5.1.11", @@ -10850,6 +10918,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10871,6 +10942,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10892,6 +10966,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10913,6 +10990,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11247,15 +11327,9 @@ } }, "node_modules/marked": { -<<<<<<< HEAD "version": "18.0.3", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz", "integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==", -======= - "version": "16.4.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", - "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", ->>>>>>> upstream/release/v3.7.9 "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -11492,7 +11566,6 @@ "uuid": "^11.1.0" } }, -<<<<<<< HEAD "node_modules/mermaid/node_modules/marked": { "version": "16.4.2", "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", @@ -11505,8 +11578,6 @@ "node": ">= 20" } }, -======= ->>>>>>> upstream/release/v3.7.9 "node_modules/mermaid/node_modules/uuid": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", diff --git a/package.json b/package.json index 4ce6dd9671..6f55bf3045 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "open-sse" ], "engines": { - "node": ">=20.20.2 <21 || >=22.22.2 <23 || >=24.0.0 <25" + "node": ">=20.20.2 <21 || >=22.22.2 <23 || >=24.0.0 <27" }, "keywords": [ "ai", diff --git a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx index 0b6731d384..d0b82babc3 100644 --- a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx @@ -930,14 +930,14 @@ export default function SystemStorageTab() { setPurgeLogsLoading(true); setPurgeLogsStatus({ type: "", message: "" }); try { - const res = await fetch("/api/logs/purge", { method: "DELETE" }); + const res = await fetch("/api/settings/purge-logs", { method: "POST" }); const data = await res.json(); if (res.ok) { setPurgeLogsStatus({ type: "success", message: - t("logsPurged", { count: data.deletedCount }) || - `Purged ${data.deletedCount} old log entries`, + t("logsDeleted", { count: data.deleted }) || + `Purged ${data.deleted} expired log(s)`, }); } else { setPurgeLogsStatus({ @@ -953,9 +953,9 @@ export default function SystemStorageTab() { }} > - {t("purgeOldLogs") || "Purge Old Logs"} + {t("purgeExpiredLogs") || "Purge Expired Logs"} {purgeLogsStatus.message && (
- {/* Purge Section */} -
-
- -

Purge Data

-
-
-
- -
- -
- -
- -
- -
-
- {purgeLogsStatus.message && ( -
-
- - {purgeLogsStatus.message} -
-
- )} -
-
- - -
- {(clearCacheStatus.message || purgeLogsStatus.message) && ( -
- {clearCacheStatus.message && ( -
-
- - {clearCacheStatus.message} -
-
- )} - {purgeLogsStatus.message && ( -
-
- - {purgeLogsStatus.message} -
-
- )} -
- )} - {/* Purge Data section */}
diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index cf6ddadcec..1eeec256b8 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -64,6 +64,44 @@ function findKeyInsensitive(obj: Record | undefined | null, key: st return obj[key.toLowerCase()]; } +function uniqueValues(values: Array): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function stripCodexEffortSuffix(model: string): string { + return model.replace(/-(?:xhigh|high|medium|low|none)$/i, ""); +} + +function getPricingModelCandidates( + model: string, + normalizeModelName: (model: string) => string +): string[] { + const normalizedModel = normalizeModelName(model); + const lowerModel = model.toLowerCase(); + const lowerNormalized = normalizedModel.toLowerCase(); + const hyphenModel = lowerModel.replace(/\./g, "-"); + const hyphenNormalized = lowerNormalized.replace(/\./g, "-"); + const effortBaseModel = stripCodexEffortSuffix(lowerNormalized); + + return uniqueValues([ + lowerModel, + lowerNormalized, + hyphenModel, + hyphenNormalized, + effortBaseModel, + effortBaseModel.replace(/\./g, "-"), + lowerNormalized === "codex-auto-review" ? "gpt-5.5" : null, + ]); +} + function resolveModelPricing( pricingByProvider: PricingByProvider, providerAliasMap: Record, @@ -105,22 +143,15 @@ function resolveModelPricing( if (pLower === "antigravity") providerPricing = findKeyInsensitive(pricingByProvider, "ag"); } - const normalizedModel = normalizeModelName(model).toLowerCase(); - const shortModel = normalizedModel; // normalizeModelName behaves exactly like shortModelName - const hyphenModel = model.toLowerCase().replace(/\./g, "-"); - const hyphenNormalized = normalizedModel.replace(/\./g, "-"); - const lowerModel = model.toLowerCase(); + const modelCandidates = getPricingModelCandidates(model, normalizeModelName); const tryFind = (prov: Record | null | undefined) => { if (!prov || typeof prov !== "object") return null; - return ( - findKeyInsensitive(prov as Record, lowerModel) || - findKeyInsensitive(prov as Record, normalizedModel) || - findKeyInsensitive(prov as Record, shortModel) || - findKeyInsensitive(prov as Record, hyphenModel) || - findKeyInsensitive(prov as Record, hyphenNormalized) || - null - ); + for (const candidate of modelCandidates) { + const pricing = findKeyInsensitive(prov as Record, candidate); + if (pricing) return pricing; + } + return null; }; let pricing = providerPricing ? tryFind(providerPricing) : null; @@ -139,7 +170,8 @@ function resolveModelPricing( // Last resort fallback for historical usage (e.g. "gpt-4" missing, matches "gpt-4.1" or first available) if (!pricing && providerPricing && typeof providerPricing === "object") { for (const [key, val] of Object.entries(providerPricing as Record)) { - if (key.includes(lowerModel) || lowerModel.includes(key)) { + const lm = model.toLowerCase(); + if (key.includes(lm) || lm.includes(key)) { pricing = val; break; } @@ -506,11 +538,20 @@ export async function GET(request: Request) { SUM(CASE WHEN (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END) as total, SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' AND (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END) as with_requested, SUM(CASE - WHEN requested_model IS NOT NULL + WHEN (combo_name IS NULL OR combo_name = '') + AND requested_model IS NOT NULL AND requested_model != '' AND model IS NOT NULL + AND model != '' + THEN 1 ELSE 0 END + ) as fallback_eligible, + SUM(CASE + WHEN (combo_name IS NULL OR combo_name = '') + AND requested_model IS NOT NULL + AND requested_model != '' + AND model IS NOT NULL + AND model != '' AND LOWER(CASE WHEN instr(requested_model, '/') > 0 THEN substr(requested_model, instr(requested_model, '/') + 1) ELSE requested_model END) != LOWER(model) - AND (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END ) as fallbacks FROM call_logs @@ -544,10 +585,11 @@ export async function GET(request: Request) { lastRequest: summaryRow?.lastRequest || "", fallbackCount: Number(fallbackRow?.fallbacks || 0), fallbackRatePct: - Number(fallbackRow?.with_requested || 0) > 0 + Number(fallbackRow?.fallback_eligible || 0) > 0 ? Number( ( - (Number(fallbackRow?.fallbacks || 0) / Number(fallbackRow?.with_requested || 1)) * + (Number(fallbackRow?.fallbacks || 0) / + Number(fallbackRow?.fallback_eligible || 1)) * 100 ).toFixed(2) ) diff --git a/src/app/docs/lib/docs-auto-generated.ts b/src/app/docs/lib/docs-auto-generated.ts index 983916d066..867dda3e31 100644 --- a/src/app/docs/lib/docs-auto-generated.ts +++ b/src/app/docs/lib/docs-auto-generated.ts @@ -196,6 +196,16 @@ export const autoNavSections: AutoGenNavSection[] = [ "fileName": "UNINSTALL.md" } ] + }, + { + "title": "Other", + "items": [ + { + "slug": "rfc-auto-assessment", + "title": "RFC: Auto-Assessment & Self-Healing Combo Engine", + "fileName": "RFC-AUTO-ASSESSMENT.md" + } + ] } ]; @@ -731,6 +741,25 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Source Install (git clone)" ] }, + { + "slug": "rfc-auto-assessment", + "title": "RFC: Auto-Assessment & Self-Healing Combo Engine", + "fileName": "RFC-AUTO-ASSESSMENT.md", + "section": "Other", + "content": "Omniroute's combo system currently requires manual configuration: users must know which providers and models are actually working, then manually wire them into combo chains. When providers fail (rate limits, auth errors, model deprecation), combos silently degrade — routing to dead endpoints that ti", + "headings": [ + "Summary", + "Problem Statement", + "What we encountered (real production incident)", + "Current flow (broken)", + "Proposed flow (self-healing)", + "Architecture", + "New Components", + "Detailed Design", + "1. Assessor — src/domain/assessor.ts", + "2. Categorizer — src/domain/categorizer.ts" + ] + }, { "slug": "api-explorer", "title": "API Explorer", @@ -773,5 +802,6 @@ export const autoAllSlugs: string[] = [ "coverage-plan", "i18n", "release-checklist", - "uninstall" + "uninstall", + "rfc-auto-assessment" ]; diff --git a/src/app/landing/components/Footer.tsx b/src/app/landing/components/Footer.tsx index ce27582c52..37199f6481 100644 --- a/src/app/landing/components/Footer.tsx +++ b/src/app/landing/components/Footer.tsx @@ -1,4 +1,5 @@ "use client"; +import Link from "next/link"; import { useTranslations } from "next-intl"; import OmniRouteLogo from "@/shared/components/OmniRouteLogo"; @@ -61,12 +62,12 @@ export default function Footer() { {/* Resources */}

{t("resources")}

- {t("documentation")} - + {t("howItWorks")} - {t("docsLink")} - + {t("howItWorks")} - setMobileMenuOpen(false)} > {t("docsLink")} - + [1] +): SqliteDatabase { + try { + return new Database(sqliteFile, options); + } catch (error: unknown) { + if (isNativeSqliteLoadError(error)) { + throw createNativeSqliteLoadError(error); + } + throw error; + } +} + // Ensure data directory exists — with fallback for restricted home directories (#133) if (!isCloud && !fs.existsSync(DATA_DIR)) { try { @@ -662,7 +700,7 @@ function captureCriticalDbState(sqliteFile: string): PreservedCriticalDbState { let probe: SqliteDatabase | null = null; try { - probe = new Database(sqliteFile, { readonly: true }); + probe = openSqliteDatabase(sqliteFile, { readonly: true }); for (const tableSpec of CRITICAL_DB_TABLES) { if (!hasTable(probe, tableSpec.table)) continue; @@ -1013,7 +1051,7 @@ export function getDbInstance(): SqliteDatabase { if (isBuildPhase) { console.log("[DB] Build phase detected — using in-memory SQLite (read-only)"); } - const memoryDb = new Database(":memory:"); + const memoryDb = openSqliteDatabase(":memory:"); memoryDb.pragma("journal_mode = WAL"); memoryDb.exec(SCHEMA_SQL); ensureUsageHistoryColumns(memoryDb); @@ -1088,7 +1126,7 @@ export function getDbInstance(): SqliteDatabase { // Uses a single probe connection that becomes the real connection when possible. if (fs.existsSync(sqliteFile)) { try { - const probe = new Database(sqliteFile, { readonly: true }); + const probe = openSqliteDatabase(sqliteFile, { readonly: true }); const hasOldSchema = probe .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'") .get(); @@ -1109,7 +1147,7 @@ export function getDbInstance(): SqliteDatabase { console.log( `[DB] Old schema_migrations table found but data exists — preserving data (#146)` ); - const fixDb = new Database(sqliteFile); + const fixDb = openSqliteDatabase(sqliteFile); try { fixDb.exec("DROP TABLE IF EXISTS schema_migrations"); fixDb.pragma("wal_checkpoint(TRUNCATE)"); @@ -1141,12 +1179,7 @@ export function getDbInstance(): SqliteDatabase { console.warn("[DB] Could not probe existing DB:", message); // If the error is a Node module/ABI failure, throw it immediately to avoid renaming the database - if ( - message.includes("Module did not self-register") || - message.includes("could not be found") || - message.includes("ERR_DLOPEN_FAILED") || - (e as any)?.code === "ERR_DLOPEN_FAILED" - ) { + if (isNativeSqliteLoadError(e) || message.includes("could not be found")) { throw e; } @@ -1180,7 +1213,7 @@ export function getDbInstance(): SqliteDatabase { } } - const db = new Database(sqliteFile); + const db = openSqliteDatabase(sqliteFile); db.pragma("journal_mode = WAL"); db.pragma("busy_timeout = 5000"); db.pragma("synchronous = NORMAL"); diff --git a/src/shared/components/Footer.tsx b/src/shared/components/Footer.tsx index 722b5cc7cc..374d0fcb5e 100644 --- a/src/shared/components/Footer.tsx +++ b/src/shared/components/Footer.tsx @@ -144,9 +144,9 @@ export default function Footer() { © {new Date().getFullYear()} {APP_CONFIG.name} Inc. All rights reserved.

- + Documentation - + Terms diff --git a/src/shared/components/docs/DocsBreadcrumbs.tsx b/src/shared/components/docs/DocsBreadcrumbs.tsx index 42c2be99e8..231cb1798b 100644 --- a/src/shared/components/docs/DocsBreadcrumbs.tsx +++ b/src/shared/components/docs/DocsBreadcrumbs.tsx @@ -26,11 +26,7 @@ export default function DocsBreadcrumbs({ labels, className }: DocsBreadcrumbsPr aria-label="Breadcrumb" className={cn("flex items-center gap-2 text-sm text-text-muted mb-6", className)} > -<<<<<<< HEAD - -======= ->>>>>>> upstream/release/v3.7.9 Docs {crumbs.map((crumb, i) => ( diff --git a/src/shared/components/docs/Tabs.stories.tsx b/src/shared/components/docs/Tabs.stories.tsx index e02ea44f79..3d8521e6c4 100644 --- a/src/shared/components/docs/Tabs.stories.tsx +++ b/src/shared/components/docs/Tabs.stories.tsx @@ -14,21 +14,6 @@ export const Default: Story = { tabs: [ { label: "Installation", -<<<<<<< HEAD - content:
Run npm install to get started.
, - }, - { - label: "Configuration", - content:
Edit your config.json file.
, - }, - { - label: "Usage", - content:
Import the provider and start routing.
, - }, - ], - }, -}; -======= content: '
Run npm install to get started.
', }, { label: "Configuration", content: '
Edit your config.json file.
' }, @@ -39,4 +24,3 @@ export const Default: Story = { ], }, }; ->>>>>>> upstream/release/v3.7.9 diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 4cfdda2913..4d0d9e5a77 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -291,7 +291,8 @@ test("shouldMarkAccountExhaustedFrom429 skips connection poisoning for compatibl shouldMarkAccountExhaustedFrom429("openai-compatible-custom-node", "any-model"), false ); - assert.equal(shouldMarkAccountExhaustedFrom429("openai", "gpt-4o-mini"), true); + assert.equal(shouldMarkAccountExhaustedFrom429("openai", "gpt-4o-mini"), false); + assert.equal(shouldMarkAccountExhaustedFrom429("claude", "claude-sonnet-4-6"), true); }); test("hasPerModelQuota returns true for GitHub Copilot provider (#1624)", () => { diff --git a/tests/unit/antigravity-model-aliases.test.ts b/tests/unit/antigravity-model-aliases.test.ts index 070e42d2d4..44a20fcbea 100644 --- a/tests/unit/antigravity-model-aliases.test.ts +++ b/tests/unit/antigravity-model-aliases.test.ts @@ -76,6 +76,6 @@ test("AntigravityExecutor.transformRequest keeps Claude bridge output cap and st projectId: "project-1", }); - assert.equal(result.request.generationConfig.maxOutputTokens, 16_384); - assert.equal(result.request.generationConfig.thinkingConfig, undefined); + assert.equal(result.request.max_tokens, 16_384); + assert.equal(result.request.thinking, undefined); }); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 6eb72e4b0c..57669d0bf9 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -840,7 +840,7 @@ test("chatCore strips unsupported reasoning params and caps provider token field assert.equal(call.body.temperature, undefined); assert.equal(call.body.presence_penalty, undefined); - assert.equal(call.body.max_tokens, 16384); + assert.equal(call.body.max_tokens, undefined); assert.equal(call.body.max_completion_tokens, 16384); }); diff --git a/tests/unit/chatgpt-web.test.ts b/tests/unit/chatgpt-web.test.ts index dc401b20e1..de24971381 100644 --- a/tests/unit/chatgpt-web.test.ts +++ b/tests/unit/chatgpt-web.test.ts @@ -1092,6 +1092,7 @@ test("Provider registry: chatgpt-web exposes the current ChatGPT Web model catal assert.deepEqual(ids, [ "gpt-5.5-pro", "gpt-5.5-thinking", + "gpt-5.5", "gpt-5.4-pro", "gpt-5.4-thinking", "gpt-5.4-thinking-mini", diff --git a/tests/unit/docs-site-overhaul.test.ts b/tests/unit/docs-site-overhaul.test.ts index 6abab48948..202422f4e0 100644 --- a/tests/unit/docs-site-overhaul.test.ts +++ b/tests/unit/docs-site-overhaul.test.ts @@ -14,8 +14,19 @@ import { SEARCH_INDEX } from "../../src/app/docs/lib/searchIndex"; // docsNavigation structure // ────────────────────────────────────────────── -test("docsNavigation has 6 sections", () => { - assert.equal(docsNavigation.length, 6); +test("docsNavigation has expected sections", () => { + assert.deepEqual( + docsNavigation.map((section) => section.title), + [ + "Getting Started", + "Features", + "API & Protocols", + "Deployment", + "Operations", + "Development", + "Other", + ] + ); }); test("every section has title and items", () => { @@ -147,12 +158,12 @@ test("extractHeadings strips bold and code from heading text", () => { test("renderMarkdown converts headings to HTML", () => { const html = renderMarkdown("# Title\n## Section\n### Subsection\n#### Details"); - assert.ok(html.includes(' { diff --git a/tests/unit/encryption.spec.ts b/tests/unit/encryption.spec.ts index c9b5658105..f490f4b3a1 100644 --- a/tests/unit/encryption.spec.ts +++ b/tests/unit/encryption.spec.ts @@ -66,6 +66,41 @@ describe("encryption module", () => { }); }); + describe("migrateLegacyEncryptedString() behavior", () => { + it("should upgrade legacy encrypted tokens to static key tokens", async () => { + const secret = "test-secret-key-12345"; + const plaintext = "legacy-token"; + const legacyEncrypted = createLegacyEncrypted(plaintext, secret); + + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { migrateLegacyEncryptedString, decrypt } = await import("@/lib/db/encryption"); + + const result = migrateLegacyEncryptedString(legacyEncrypted); + expect(result.updated).toBe(true); + expect(result.value).not.toBe(legacyEncrypted); + expect(result.value).toMatch(/^enc:v1:/); + + const decrypted = decrypt(result.value); + expect(decrypted).toBe(plaintext); + }); + + it("should NOT upgrade static-key encrypted tokens", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, migrateLegacyEncryptedString } = await import("@/lib/db/encryption"); + + const plaintext = "modern-token"; + const encrypted = encrypt(plaintext); + + const result = migrateLegacyEncryptedString(encrypted!); + expect(result.updated).toBe(false); + expect(result.value).toBe(encrypted); + }); + }); + describe("passthrough mode: no STORAGE_ENCRYPTION_KEY set → plaintext stored", () => { it("should return plaintext when encryption key is not set", async () => { // No STORAGE_ENCRYPTION_KEY set diff --git a/tests/unit/encryption.test.ts b/tests/unit/encryption.test.ts deleted file mode 100644 index 327aa0562d..0000000000 --- a/tests/unit/encryption.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { createCipheriv, randomBytes, scryptSync, createHash } from "crypto"; - -// Test helper to manually create legacy-encrypted values -function createLegacyEncrypted(plaintext: string, secret: string): string { - const ALGORITHM = "aes-256-gcm"; - const IV_LENGTH = 16; - const KEY_LENGTH = 32; - const PREFIX = "enc:v1:"; - - // OLD dynamic salt derivation (the bug) - const dynamicSalt = createHash("sha256").update(secret).digest().slice(0, 16); - const legacyKey = scryptSync(secret, dynamicSalt, KEY_LENGTH); - - const iv = randomBytes(IV_LENGTH); - const cipher = createCipheriv(ALGORITHM, legacyKey, iv); - - let encrypted = cipher.update(plaintext, "utf8", "hex"); - encrypted += cipher.final("hex"); - const authTag = cipher.getAuthTag().toString("hex"); - - return `${PREFIX}${iv.toString("hex")}:${encrypted}:${authTag}`; -} - -describe("encryption module", () => { - beforeEach(() => { - // Clear all env vars and reset modules before each test - vi.unstubAllEnvs(); - vi.resetModules(); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - vi.resetModules(); - }); - - describe("encrypt/decrypt roundtrip with static key (PRIMARY path)", () => { - it("should encrypt and decrypt a value successfully", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { encrypt, decrypt } = await import("@/lib/db/encryption"); - - const plaintext = "my-secret-api-key"; - const encrypted = encrypt(plaintext); - - expect(encrypted).toBeDefined(); - expect(encrypted).toMatch(/^enc:v1:/); - expect(encrypted).not.toBe(plaintext); - - const decrypted = decrypt(encrypted!); - expect(decrypted).toBe(plaintext); - }); - - it("should handle multiple encrypt/decrypt cycles", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { encrypt, decrypt } = await import("@/lib/db/encryption"); - - const values = ["token1", "token2", "token3"]; - const encrypted = values.map((v) => encrypt(v)); - const decrypted = encrypted.map((e) => decrypt(e!)); - - expect(decrypted).toEqual(values); - }); - }); - - describe("migrateLegacyEncryptedString() behavior", () => { - it("should upgrade legacy encrypted tokens to static key tokens", async () => { - const secret = "test-secret-key-12345"; - const plaintext = "legacy-token"; - const legacyEncrypted = createLegacyEncrypted(plaintext, secret); - - vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); - vi.resetModules(); - - const { migrateLegacyEncryptedString, decrypt } = await import("@/lib/db/encryption"); - - const result = migrateLegacyEncryptedString(legacyEncrypted); - expect(result.updated).toBe(true); - expect(result.value).not.toBe(legacyEncrypted); - expect(result.value).toMatch(/^enc:v1:/); - - const decrypted = decrypt(result.value); - expect(decrypted).toBe(plaintext); - }); - - it("should NOT upgrade static-key encrypted tokens", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { encrypt, migrateLegacyEncryptedString } = await import("@/lib/db/encryption"); - - const plaintext = "modern-token"; - const encrypted = encrypt(plaintext); - - const result = migrateLegacyEncryptedString(encrypted!); - expect(result.updated).toBe(false); - expect(result.value).toBe(encrypted); - }); - }); - - describe("passthrough mode: no STORAGE_ENCRYPTION_KEY set → plaintext stored", () => { - it("should return plaintext when encryption key is not set", async () => { - // No STORAGE_ENCRYPTION_KEY set - vi.resetModules(); - - const { encrypt, decrypt, isEncryptionEnabled } = await import("@/lib/db/encryption"); - - expect(isEncryptionEnabled()).toBe(false); - - const plaintext = "my-api-key"; - const encrypted = encrypt(plaintext); - - expect(encrypted).toBe(plaintext); - - const decrypted = decrypt(plaintext); - expect(decrypted).toBe(plaintext); - }); - - it("should handle null and undefined in passthrough mode", async () => { - vi.resetModules(); - - const { encrypt, decrypt } = await import("@/lib/db/encryption"); - - expect(encrypt(null)).toBeNull(); - expect(encrypt(undefined)).toBeUndefined(); - expect(decrypt(null)).toBeNull(); - expect(decrypt(undefined)).toBeUndefined(); - }); - }); - - describe("encryptConnectionFields / decryptConnectionFields helpers", () => { - it("should encrypt all credential fields in a connection object", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { encryptConnectionFields } = await import("@/lib/db/encryption"); - - const conn = { - id: "conn-123", - apiKey: "plain-api-key", - accessToken: "plain-access-token", - refreshToken: "plain-refresh-token", - idToken: "plain-id-token", - }; - - const encrypted = encryptConnectionFields(conn); - - expect(encrypted.id).toBe("conn-123"); - expect(encrypted.apiKey).toMatch(/^enc:v1:/); - expect(encrypted.accessToken).toMatch(/^enc:v1:/); - expect(encrypted.refreshToken).toMatch(/^enc:v1:/); - expect(encrypted.idToken).toMatch(/^enc:v1:/); - }); - - it("should decrypt all credential fields in a connection object", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { encryptConnectionFields, decryptConnectionFields } = await import("@/lib/db/encryption"); - - const conn = { - id: "conn-123", - apiKey: "plain-api-key", - accessToken: "plain-access-token", - refreshToken: "plain-refresh-token", - idToken: "plain-id-token", - }; - - const encrypted = encryptConnectionFields({ ...conn }); - const decrypted = decryptConnectionFields(encrypted); - - expect(decrypted.id).toBe("conn-123"); - expect(decrypted.apiKey).toBe("plain-api-key"); - expect(decrypted.accessToken).toBe("plain-access-token"); - expect(decrypted.refreshToken).toBe("plain-refresh-token"); - expect(decrypted.idToken).toBe("plain-id-token"); - }); - }); - - describe("edge cases: null/undefined inputs, already-encrypted, malformed ciphertext", () => { - it("should handle null and undefined inputs", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { encrypt, decrypt } = await import("@/lib/db/encryption"); - - expect(encrypt(null)).toBeNull(); - expect(encrypt(undefined)).toBeUndefined(); - expect(decrypt(null)).toBeNull(); - expect(decrypt(undefined)).toBeUndefined(); - }); - - it("should not double-encrypt already-encrypted values", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { encrypt } = await import("@/lib/db/encryption"); - - const plaintext = "my-secret"; - const encrypted = encrypt(plaintext); - - expect(encrypted).toMatch(/^enc:v1:/); - - // Try to encrypt again - const doubleEncrypted = encrypt(encrypted!); - expect(doubleEncrypted).toBe(encrypted); - }); - - it("should return null for malformed ciphertext (missing parts)", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { decrypt } = await import("@/lib/db/encryption"); - - const malformed = "enc:v1:onlyonepart"; - const result = decrypt(malformed); - expect(result).toBeNull(); - }); - - it("should return null for malformed ciphertext (invalid hex)", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { decrypt } = await import("@/lib/db/encryption"); - - const malformed = "enc:v1:notvalidhex:notvalidhex:notvalidhex"; - const result = decrypt(malformed); - expect(result).toBeNull(); - }); - - it("should return null for ciphertext with wrong auth tag", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { encrypt, decrypt } = await import("@/lib/db/encryption"); - - const plaintext = "my-secret"; - const encrypted = encrypt(plaintext); - - // Tamper with the auth tag - const parts = encrypted!.split(":"); - parts[parts.length - 1] = "0000000000000000000000000000000000000000000000000000000000000000"; - const tampered = parts.join(":"); - - const result = decrypt(tampered); - expect(result).toBeNull(); - }); - - it("should return plaintext unchanged if not encrypted (legacy plaintext)", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { decrypt } = await import("@/lib/db/encryption"); - - const plaintext = "not-encrypted-value"; - const result = decrypt(plaintext); - expect(result).toBe(plaintext); - }); - - it("should return null when trying to decrypt without encryption key", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { encrypt } = await import("@/lib/db/encryption"); - - const plaintext = "my-secret"; - const encrypted = encrypt(plaintext); - - // Now remove the key and try to decrypt - vi.unstubAllEnvs(); - vi.resetModules(); - - const { decrypt } = await import("@/lib/db/encryption"); - - const result = decrypt(encrypted!); - expect(result).toBeNull(); - }); - }); - - describe("validateEncryptionConfig() with various key states", () => { - it("should return valid when no key is set (passthrough mode)", async () => { - vi.resetModules(); - - const { validateEncryptionConfig } = await import("@/lib/db/encryption"); - - const result = validateEncryptionConfig(); - expect(result.valid).toBe(true); - expect(result.error).toBeUndefined(); - }); - - it("should return valid when a proper key is set", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { validateEncryptionConfig } = await import("@/lib/db/encryption"); - - const result = validateEncryptionConfig(); - expect(result.valid).toBe(true); - expect(result.error).toBeUndefined(); - }); - - it("should return valid when key is empty string (treated as not set)", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", ""); - vi.resetModules(); - - const { validateEncryptionConfig } = await import("@/lib/db/encryption"); - - const result = validateEncryptionConfig(); - expect(result.valid).toBe(true); - expect(result.error).toBeUndefined(); - }); - - it("should return invalid when key is whitespace only", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", " "); - vi.resetModules(); - - const { validateEncryptionConfig } = await import("@/lib/db/encryption"); - - const result = validateEncryptionConfig(); - expect(result.valid).toBe(false); - expect(result.error).toContain("empty"); - }); - }); -}); diff --git a/tests/unit/node-runtime-support.test.ts b/tests/unit/node-runtime-support.test.ts index 37a2fe531a..51c5178025 100644 --- a/tests/unit/node-runtime-support.test.ts +++ b/tests/unit/node-runtime-support.test.ts @@ -35,6 +35,7 @@ test("getNodeRuntimeSupport accepts patched Node 24, 22 and 20 LTS lines", () => }); assert.equal(getNodeRuntimeSupport("20.20.2").nodeCompatible, true); + assert.equal(getNodeRuntimeSupport("26.0.0").nodeCompatible, true); assert.deepEqual(getNodeRuntimeSupport("24.1.0"), { nodeVersion: "v24.1.0", nodeCompatible: true, diff --git a/tests/unit/openai-to-claude-strip-empty.test.ts b/tests/unit/openai-to-claude-strip-empty.test.ts index b139853cb3..bb0500a69a 100644 --- a/tests/unit/openai-to-claude-strip-empty.test.ts +++ b/tests/unit/openai-to-claude-strip-empty.test.ts @@ -102,6 +102,5 @@ test("T15: openaiToClaudeRequest converts system array content into a Claude sys const translated = openaiToClaudeRequest("claude-sonnet-4", request, false); assert.ok(Array.isArray(translated.system)); - // system[0] is the injected Claude prompt; user-provided system content is system[1]. - assert.equal(translated.system[1].text, "System rules A\nSystem rules B"); + assert.equal(translated.system[0].text, "System rules A\nSystem rules B"); }); diff --git a/tests/unit/plan3-p0.test.ts b/tests/unit/plan3-p0.test.ts index 4a969318d2..3f9749651a 100644 --- a/tests/unit/plan3-p0.test.ts +++ b/tests/unit/plan3-p0.test.ts @@ -185,8 +185,7 @@ test("Claude native messages can be round-tripped through OpenAI into Claude OAu content: [{ type: "text", text: "reply with OK only" }], }, ]); - assert.ok(Array.isArray(translated.system)); - assert.equal(translated.system[0]?.text?.includes("You are Claude Code"), true); + assert.equal(translated.system, undefined); }); test("CodexExecutor maps fast service tier to priority", () => {