diff --git a/package.json b/package.json index ed26ce1734..c820685ec2 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,8 @@ "bluebird": ">=3.7.2", "glob": ">=11.1.0", "@types/react": "^18.3.23", - "@types/react-dom": "^18.3.5" + "@types/react-dom": "^18.3.5", + "zod": "3.25.76" } } } diff --git a/packages/types/package.json b/packages/types/package.json index 09fac8d672..d66d87ac72 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -23,7 +23,7 @@ "clean": "rimraf dist .turbo" }, "dependencies": { - "zod": "^3.25.61" + "zod": "3.25.76" }, "devDependencies": { "@roo-code/config-eslint": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 177d0b3e5a..119ec44d95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,7 @@ overrides: glob: '>=11.1.0' '@types/react': ^18.3.23 '@types/react-dom': ^18.3.5 + zod: 3.25.76 importers: @@ -262,7 +263,7 @@ importers: version: 0.518.0(react@18.3.1) next: specifier: ~15.2.8 - version: 15.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 15.2.8(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -297,8 +298,8 @@ importers: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) zod: - specifier: ^3.25.61 - version: 3.25.61 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@roo-code/config-eslint': specifier: workspace:^ @@ -371,7 +372,7 @@ importers: version: 0.518.0(react@18.3.1) next: specifier: ~15.2.8 - version: 15.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 15.2.8(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -412,8 +413,8 @@ importers: specifier: ^6.1.86 version: 6.1.86 zod: - specifier: ^3.25.61 - version: 3.25.61 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@roo-code/config-eslint': specifier: workspace:^ @@ -438,7 +439,7 @@ importers: version: 10.4.21(postcss@8.5.4) next-sitemap: specifier: ^4.2.3 - version: 4.2.3(next@15.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 4.2.3(next@15.2.8(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) postcss: specifier: ^8.5.4 version: 8.5.4 @@ -449,8 +450,8 @@ importers: packages/build: dependencies: zod: - specifier: ^3.25.61 - version: 3.25.61 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@roo-code/config-eslint': specifier: workspace:^ @@ -483,7 +484,7 @@ importers: specifier: ^4.8.1 version: 4.8.1 zod: - specifier: ^3.25.76 + specifier: 3.25.76 version: 3.25.76 devDependencies: '@roo-code/config-eslint': @@ -555,7 +556,7 @@ importers: specifier: ^5.12.2 version: 5.12.2(ws@8.18.3)(zod@3.25.76) zod: - specifier: ^3.25.61 + specifier: 3.25.76 version: 3.25.76 devDependencies: '@roo-code/config-eslint': @@ -584,7 +585,7 @@ importers: version: 0.13.0 drizzle-orm: specifier: ^0.44.1 - version: 0.44.1(@libsql/client@0.15.8)(better-sqlite3@11.10.0)(gel@2.1.0)(postgres@3.4.7) + version: 0.44.1(@libsql/client@0.15.8)(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(gel@2.1.0)(postgres@3.4.7) execa: specifier: ^9.6.0 version: 9.6.0 @@ -610,8 +611,8 @@ importers: specifier: ^5.5.5 version: 5.5.5 zod: - specifier: ^3.25.61 - version: 3.25.61 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@roo-code/config-eslint': specifier: workspace:^ @@ -672,8 +673,8 @@ importers: specifier: ^5.0.0 version: 5.1.1 zod: - specifier: ^3.25.61 - version: 3.25.61 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@roo-code/config-eslint': specifier: workspace:^ @@ -694,8 +695,8 @@ importers: packages/types: dependencies: zod: - specifier: ^3.25.61 - version: 3.25.61 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@roo-code/config-eslint': specifier: workspace:^ @@ -756,7 +757,7 @@ importers: version: 1.2.0 '@mistralai/mistralai': specifier: ^1.9.18 - version: 1.9.18(zod@3.25.61) + version: 1.9.18(zod@3.25.76) '@modelcontextprotocol/sdk': specifier: 1.12.0 version: 1.12.0 @@ -867,7 +868,7 @@ importers: version: 0.5.17 openai: specifier: ^5.12.2 - version: 5.12.2(ws@8.18.3)(zod@3.25.61) + version: 5.12.2(ws@8.18.3)(zod@3.25.76) os-name: specifier: ^6.0.0 version: 6.1.0 @@ -974,9 +975,12 @@ importers: specifier: ^2.8.0 version: 2.8.0 zod: - specifier: 3.25.61 - version: 3.25.61 + specifier: 3.25.76 + version: 3.25.76 devDependencies: + '@openrouter/ai-sdk-provider': + specifier: ^1.5.4 + version: 1.5.4(ai@5.0.121(zod@3.25.76))(zod@3.25.76) '@roo-code/build': specifier: workspace:^ version: link:../packages/build @@ -1049,6 +1053,9 @@ importers: '@vscode/vsce': specifier: 3.3.2 version: 3.3.2 + ai: + specifier: ^5.0.0 + version: 5.0.121(zod@3.25.76) esbuild-wasm: specifier: ^0.25.0 version: 0.25.12 @@ -1084,7 +1091,7 @@ importers: version: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) zod-to-ts: specifier: ^1.2.0 - version: 1.2.0(typescript@5.8.3)(zod@3.25.61) + version: 1.2.0(typescript@5.8.3)(zod@3.25.76) webview-ui: dependencies: @@ -1287,8 +1294,8 @@ importers: specifier: ^0.2.2 version: 0.2.2(@types/react@18.3.23)(react@18.3.1) zod: - specifier: ^3.25.61 - version: 3.25.61 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@roo-code/config-eslint': specifier: workspace:^ @@ -1356,6 +1363,22 @@ packages: '@adobe/css-tools@4.4.2': resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==} + '@ai-sdk/gateway@2.0.27': + resolution: {integrity: sha512-8hbezMsGa0crSt7/DKjkYL1UbbJJW/UFxTfhmf5qcIeYeeWG4dTNmm+DWbUdIsTaWvp59KC4eeC9gYXBbTHd7w==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/provider-utils@3.0.20': + resolution: {integrity: sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/provider@2.0.1': + resolution: {integrity: sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==} + engines: {node: '>=18'} + '@alcalzone/ansi-tokenize@0.2.3': resolution: {integrity: sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==} engines: {node: '>=18'} @@ -2374,7 +2397,7 @@ packages: '@mistralai/mistralai@1.9.18': resolution: {integrity: sha512-D/vNAGEvWMsg95tzgLTg7pPnW9leOPyH+nh1Os05NwxVPbUykoYgMAwOEX7J46msahWdvZ4NQQuxUXIUV2P6dg==} peerDependencies: - zod: '>= 3' + zod: 3.25.76 '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} @@ -2573,6 +2596,20 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@openrouter/ai-sdk-provider@1.5.4': + resolution: {integrity: sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw==} + engines: {node: '>=18'} + peerDependencies: + ai: ^5.0.0 + zod: 3.25.76 + + '@openrouter/sdk@0.1.27': + resolution: {integrity: sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ==} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@oxc-resolver/binding-darwin-arm64@11.2.0': resolution: {integrity: sha512-ruKLkS+Dm/YIJaUhzEB7zPI+jh3EXxu0QnNV8I7t9jf0lpD2VnltuyRbhrbJEkksklZj//xCMyFFsILGjiU2Mg==} cpu: [arm64] @@ -3801,6 +3838,9 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -4421,6 +4461,10 @@ packages: resolution: {integrity: sha512-e4kQK9mP8ntpo3dACWirGod/hHv4qO5JMj9a/0a2AZto7b4persj5YP7t1Er372gTtYFTYxNhMx34jRvHooglw==} engines: {node: '>=16'} + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@4.4.1': resolution: {integrity: sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==} engines: {node: ^14.18.0 || >=16.0.0} @@ -4573,6 +4617,12 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} + ai@5.0.121: + resolution: {integrity: sha512-3iYPdARKGLryC/7OA9RgBUaym1gynvWS7UPy8NwoRNCKP52lshldtHB5xcEfVviw7liWH2zJlW9yEzsDglcIEQ==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -6152,6 +6202,10 @@ packages: resolution: {integrity: sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA==} engines: {node: '>=18.0.0'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + eventsource@3.0.7: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} @@ -7271,6 +7325,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -8265,7 +8322,7 @@ packages: hasBin: true peerDependencies: ws: ^8.18.0 - zod: ^3.23.8 + zod: 3.25.76 peerDependenciesMeta: ws: optional: true @@ -10636,25 +10693,19 @@ packages: zod-to-json-schema@3.24.5: resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} peerDependencies: - zod: ^3.24.1 + zod: 3.25.76 zod-to-ts@1.2.0: resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} peerDependencies: typescript: ^4.9.4 || ^5.0.2 - zod: ^3 + zod: 3.25.76 zod-validation-error@3.4.1: resolution: {integrity: sha512-1KP64yqDPQ3rupxNv7oXhf7KdhHHgaqbKuspVoiN93TT0xrBjql+Svjkdjq/Qh/7GSMmgQs3AfvBT0heE35thw==} engines: {node: '>=18.0.0'} peerDependencies: - zod: ^3.24.4 - - zod@3.23.8: - resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} - - zod@3.25.61: - resolution: {integrity: sha512-fzfJgUw78LTNnHujj9re1Ov/JJQkRZZGDMcYqSx7Hp4rPOkKywaFHq0S6GoHeXs0wGNE/sIOutkXgnwzrVOGCQ==} + zod: 3.25.76 zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -10684,6 +10735,24 @@ snapshots: '@adobe/css-tools@4.4.2': {} + '@ai-sdk/gateway@2.0.27(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.1 + '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) + '@vercel/oidc': 3.1.0 + zod: 3.25.76 + + '@ai-sdk/provider-utils@3.0.20(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.1 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + + '@ai-sdk/provider@2.0.1': + dependencies: + json-schema: 0.4.0 + '@alcalzone/ansi-tokenize@0.2.3': dependencies: ansi-styles: 6.2.3 @@ -12198,10 +12267,10 @@ snapshots: dependencies: exenv-es6: 1.1.1 - '@mistralai/mistralai@1.9.18(zod@3.25.61)': + '@mistralai/mistralai@1.9.18(zod@3.25.76)': dependencies: - zod: 3.25.61 - zod-to-json-schema: 3.24.5(zod@3.25.61) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) '@mixmark-io/domino@2.2.0': {} @@ -12369,6 +12438,18 @@ snapshots: '@open-draft/until@2.1.0': {} + '@openrouter/ai-sdk-provider@1.5.4(ai@5.0.121(zod@3.25.76))(zod@3.25.76)': + dependencies: + '@openrouter/sdk': 0.1.27 + ai: 5.0.121(zod@3.25.76) + zod: 3.25.76 + + '@openrouter/sdk@0.1.27': + dependencies: + zod: 3.25.76 + + '@opentelemetry/api@1.9.0': {} + '@oxc-resolver/binding-darwin-arm64@11.2.0': optional: true @@ -13735,6 +13816,8 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} '@swc/counter@0.1.3': {} @@ -14392,6 +14475,8 @@ snapshots: satori: 0.12.2 yoga-wasm-web: 0.3.3 + '@vercel/oidc@3.1.0': {} + '@vitejs/plugin-react@4.4.1(vite@6.3.6(@types/node@20.17.57)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0))': dependencies: '@babel/core': 7.27.1 @@ -14614,6 +14699,14 @@ snapshots: dependencies: humanize-ms: 1.2.1 + ai@5.0.121(zod@3.25.76): + dependencies: + '@ai-sdk/gateway': 2.0.27(zod@3.25.76) + '@ai-sdk/provider': 2.0.1 + '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) + '@opentelemetry/api': 1.9.0 + zod: 3.25.76 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -15141,7 +15234,7 @@ snapshots: dependencies: devtools-protocol: 0.0.1367902 mitt: 3.0.1 - zod: 3.23.8 + zod: 3.25.76 chromium-bidi@5.1.0(devtools-protocol@0.0.1452169): dependencies: @@ -15819,9 +15912,10 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.44.1(@libsql/client@0.15.8)(better-sqlite3@11.10.0)(gel@2.1.0)(postgres@3.4.7): + drizzle-orm@0.44.1(@libsql/client@0.15.8)(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(gel@2.1.0)(postgres@3.4.7): optionalDependencies: '@libsql/client': 0.15.8 + '@opentelemetry/api': 1.9.0 better-sqlite3: 11.10.0 gel: 2.1.0 postgres: 3.4.7 @@ -16296,6 +16390,8 @@ snapshots: eventsource-parser@3.0.2: {} + eventsource-parser@3.0.6: {} + eventsource@3.0.7: dependencies: eventsource-parser: 3.0.2 @@ -17598,6 +17694,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: {} @@ -18647,20 +18745,20 @@ snapshots: netmask@2.0.2: {} - next-sitemap@4.2.3(next@15.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + next-sitemap@4.2.3(next@15.2.8(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.11 fast-glob: 3.3.3 minimist: 1.2.8 - next: 15.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.2.8(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next@15.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@15.2.8(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 15.2.8 '@swc/counter': 0.1.3 @@ -18680,6 +18778,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.2.5 '@next/swc-win32-arm64-msvc': 15.2.5 '@next/swc-win32-x64-msvc': 15.2.5 + '@opentelemetry/api': 1.9.0 sharp: 0.33.5 transitivePeerDependencies: - '@babel/core' @@ -18852,11 +18951,6 @@ snapshots: is-inside-container: 1.0.0 is-wsl: 3.1.0 - openai@5.12.2(ws@8.18.3)(zod@3.25.61): - optionalDependencies: - ws: 8.18.3 - zod: 3.25.61 - openai@5.12.2(ws@8.18.3)(zod@3.25.76): optionalDependencies: ws: 8.18.3 @@ -21692,27 +21786,19 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 - zod-to-json-schema@3.24.5(zod@3.25.61): - dependencies: - zod: 3.25.61 - zod-to-json-schema@3.24.5(zod@3.25.76): dependencies: zod: 3.25.76 - zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.25.61): + zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.25.76): dependencies: typescript: 5.8.3 - zod: 3.25.61 + zod: 3.25.76 zod-validation-error@3.4.1(zod@3.25.76): dependencies: zod: 3.25.76 - zod@3.23.8: {} - - zod@3.25.61: {} - zod@3.25.76: {} zustand@5.0.9(@types/react@18.3.23)(react@19.2.3): diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 8875df9a47..497ae8e20e 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -3,13 +3,31 @@ vitest.mock("vscode", () => ({})) import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" import { OpenRouterHandler } from "../openrouter" import { ApiHandlerOptions } from "../../../shared/api" -import { Package } from "../../../shared/package" -vitest.mock("openai") +// Mock the AI SDK +const mockStreamText = vitest.fn() +const mockGenerateText = vitest.fn() +const mockCreateOpenRouter = vitest.fn() + +vitest.mock("ai", () => ({ + streamText: (...args: unknown[]) => mockStreamText(...args), + generateText: (...args: unknown[]) => mockGenerateText(...args), + tool: vitest.fn((t) => t), + jsonSchema: vitest.fn((s) => s), +})) + +vitest.mock("@openrouter/ai-sdk-provider", () => ({ + createOpenRouter: (...args: unknown[]) => { + mockCreateOpenRouter(...args) + return { + chat: vitest.fn((modelId: string) => ({ modelId })), + } + }, +})) + vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) })) const mockCaptureException = vitest.fn() @@ -85,6 +103,11 @@ vitest.mock("../fetchers/modelCache", () => ({ }, }) }), + getModelsFromCache: vitest.fn().mockReturnValue(null), +})) + +vitest.mock("../fetchers/modelEndpointCache", () => ({ + getModelEndpoints: vitest.fn().mockResolvedValue({}), })) describe("OpenRouterHandler", () => { @@ -93,21 +116,13 @@ describe("OpenRouterHandler", () => { openRouterModelId: "anthropic/claude-sonnet-4", } - beforeEach(() => vitest.clearAllMocks()) + beforeEach(() => { + vitest.clearAllMocks() + }) it("initializes with correct options", () => { const handler = new OpenRouterHandler(mockOptions) expect(handler).toBeInstanceOf(OpenRouterHandler) - - expect(OpenAI).toHaveBeenCalledWith({ - baseURL: "https://openrouter.ai/api/v1", - apiKey: mockOptions.openRouterApiKey, - defaultHeaders: { - "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", - "X-Title": "Roo Code", - "User-Agent": `RooCode/${Package.version}`, - }, - }) }) describe("fetchModel", () => { @@ -211,26 +226,20 @@ describe("OpenRouterHandler", () => { it("generates correct stream chunks", async () => { const handler = new OpenRouterHandler(mockOptions) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - id: mockOptions.openRouterModelId, - choices: [{ delta: { content: "test response" } }], - } - yield { - id: "test-id", - choices: [{ delta: {} }], - usage: { prompt_tokens: 10, completion_tokens: 20, cost: 0.001 }, - } - }, - } + // Create mock async iterator for fullStream + const mockFullStream = (async function* () { + yield { type: "text-delta", text: "test response", id: "1" } + })() - // Mock OpenAI chat.completions.create - const mockCreate = vitest.fn().mockResolvedValue(mockStream) + // Mock usage promises + const mockUsage = Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }) + const mockTotalUsage = Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: mockUsage, + totalUsage: mockTotalUsage, + }) const systemPrompt = "test system prompt" const messages: Anthropic.Messages.MessageParam[] = [{ role: "user" as const, content: "test message" }] @@ -242,464 +251,296 @@ describe("OpenRouterHandler", () => { chunks.push(chunk) } - // Verify stream chunks - expect(chunks).toHaveLength(2) // One text chunk and one usage chunk + // Verify stream chunks - should have text and usage chunks + expect(chunks).toHaveLength(2) expect(chunks[0]).toEqual({ type: "text", text: "test response" }) - expect(chunks[1]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20, totalCost: 0.001 }) + expect(chunks[1]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20 }) - // Verify OpenAI client was called with correct parameters. - expect(mockCreate).toHaveBeenCalledWith( + // Verify streamText was called with correct parameters + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - max_tokens: 8192, - messages: [ - { - content: [ - { cache_control: { type: "ephemeral" }, text: "test system prompt", type: "text" }, - ], - role: "system", - }, - { - content: [{ cache_control: { type: "ephemeral" }, text: "test message", type: "text" }], - role: "user", - }, - ], - model: "anthropic/claude-sonnet-4", - stream: true, - stream_options: { include_usage: true }, + system: systemPrompt, + messages: expect.any(Array), + maxOutputTokens: 8192, temperature: 0, - top_p: undefined, }), - { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }, ) }) - it("adds cache control for supported models", async () => { - const handler = new OpenRouterHandler({ - ...mockOptions, - openRouterModelId: "anthropic/claude-3.5-sonnet", - }) + it("handles reasoning delta chunks", async () => { + const handler = new OpenRouterHandler(mockOptions) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - id: "test-id", - choices: [{ delta: { content: "test response" } }], - } - }, - } + const mockFullStream = (async function* () { + yield { type: "reasoning-delta", text: "thinking...", id: "1" } + yield { type: "text-delta", text: "result", id: "2" } + })() - const mockCreate = vitest.fn().mockResolvedValue(mockStream) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }), + totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }), + }) - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "message 1" }, - { role: "assistant", content: "response 1" }, - { role: "user", content: "message 2" }, - ] + const generator = handler.createMessage("test", [{ role: "user", content: "test" }]) + const chunks = [] - await handler.createMessage("test system", messages).next() + for await (const chunk of generator) { + chunks.push(chunk) + } - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - messages: expect.arrayContaining([ - expect.objectContaining({ - role: "system", - content: expect.arrayContaining([ - expect.objectContaining({ cache_control: { type: "ephemeral" } }), - ]), - }), - ]), - }), - { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }, - ) + expect(chunks[0]).toEqual({ type: "reasoning", text: "thinking..." }) + expect(chunks[1]).toEqual({ type: "text", text: "result" }) }) - it("handles API errors and captures telemetry", async () => { + it("handles tool call streaming", async () => { const handler = new OpenRouterHandler(mockOptions) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { error: { message: "API Error", code: 500 } } - }, - } - const mockCreate = vitest.fn().mockResolvedValue(mockStream) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any - - const generator = handler.createMessage("test", []) - await expect(generator.next()).rejects.toThrow("OpenRouter API Error 500: API Error") - - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "API Error", - provider: "OpenRouter", - modelId: mockOptions.openRouterModelId, - operation: "createMessage", - errorCode: 500, - status: 500, - }), - ) - }) + const mockFullStream = (async function* () { + yield { type: "tool-input-start", id: "call_1", toolName: "read_file" } + yield { type: "tool-input-delta", id: "call_1", delta: '{"path":' } + yield { type: "tool-input-delta", id: "call_1", delta: '"test.ts"}' } + yield { type: "tool-input-end", id: "call_1" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }), + totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }), + }) - it("captures telemetry when createMessage throws an exception", async () => { - const handler = new OpenRouterHandler(mockOptions) - const mockCreate = vitest.fn().mockRejectedValue(new Error("Connection failed")) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + const generator = handler.createMessage("test", [{ role: "user", content: "test" }]) + const chunks = [] - const generator = handler.createMessage("test", []) - await expect(generator.next()).rejects.toThrow() + for await (const chunk of generator) { + chunks.push(chunk) + } - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Connection failed", - provider: "OpenRouter", - modelId: mockOptions.openRouterModelId, - operation: "createMessage", - }), - ) + expect(chunks[0]).toEqual({ type: "tool_call_start", id: "call_1", name: "read_file" }) + expect(chunks[1]).toEqual({ type: "tool_call_delta", id: "call_1", delta: '{"path":' }) + expect(chunks[2]).toEqual({ type: "tool_call_delta", id: "call_1", delta: '"test.ts"}' }) + expect(chunks[3]).toEqual({ type: "tool_call_end", id: "call_1" }) }) - it("passes SDK exceptions with status 429 to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + it("handles complete tool call events", async () => { const handler = new OpenRouterHandler(mockOptions) - const error = new Error("Rate limit exceeded: free-models-per-day") as any - error.status = 429 - - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any - const generator = handler.createMessage("test", []) - await expect(generator.next()).rejects.toThrow("Rate limit exceeded") + const mockFullStream = (async function* () { + yield { + type: "tool-call", + toolCallId: "call_1", + toolName: "read_file", + input: { path: "test.ts" }, + } + })() - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Rate limit exceeded: free-models-per-day", - provider: "OpenRouter", - modelId: mockOptions.openRouterModelId, - operation: "createMessage", - }), - ) - }) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }), + totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }), + }) - it("passes SDK exceptions with 429 in message to telemetry (filtering happens in PostHogTelemetryClient)", async () => { - const handler = new OpenRouterHandler(mockOptions) - const error = new Error("429 Rate limit exceeded: free-models-per-day") - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + const generator = handler.createMessage("test", [{ role: "user", content: "test" }]) + const chunks = [] - const generator = handler.createMessage("test", []) - await expect(generator.next()).rejects.toThrow("429 Rate limit exceeded") + for await (const chunk of generator) { + chunks.push(chunk) + } - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "429 Rate limit exceeded: free-models-per-day", - provider: "OpenRouter", - modelId: mockOptions.openRouterModelId, - operation: "createMessage", - }), - ) + expect(chunks[0]).toEqual({ + type: "tool_call", + id: "call_1", + name: "read_file", + arguments: '{"path":"test.ts"}', + }) }) - it("passes SDK exceptions containing 'rate limit' to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + it("handles API errors gracefully", async () => { const handler = new OpenRouterHandler(mockOptions) - const error = new Error("Request failed due to rate limit") - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any - const generator = handler.createMessage("test", []) - await expect(generator.next()).rejects.toThrow("rate limit") + mockStreamText.mockImplementation(() => { + throw new Error("API Error") + }) - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Request failed due to rate limit", - provider: "OpenRouter", - modelId: mockOptions.openRouterModelId, - operation: "createMessage", - }), - ) - }) + const generator = handler.createMessage("test", [{ role: "user", content: "test" }]) + const chunks = [] - it("passes 429 rate limit errors from stream to telemetry (filtering happens in PostHogTelemetryClient)", async () => { - const handler = new OpenRouterHandler(mockOptions) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { error: { message: "Rate limit exceeded", code: 429 } } - }, + for await (const chunk of generator) { + chunks.push(chunk) } - const mockCreate = vitest.fn().mockResolvedValue(mockStream) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any - - const generator = handler.createMessage("test", []) - await expect(generator.next()).rejects.toThrow("OpenRouter API Error 429: Rate limit exceeded") - - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Rate limit exceeded", - provider: "OpenRouter", - modelId: mockOptions.openRouterModelId, - operation: "createMessage", - errorCode: 429, - status: 429, - }), - ) + expect(chunks[0]).toEqual({ + type: "error", + error: "OpenRouterError", + message: "OpenRouter API Error: API Error", + }) }) - it("yields tool_call_end events when finish_reason is tool_calls", async () => { - // Import NativeToolCallParser to set up state - const { NativeToolCallParser } = await import("../../../core/assistant-message/NativeToolCallParser") - - // Clear any previous state - NativeToolCallParser.clearRawChunkState() - + it("handles stream errors", async () => { const handler = new OpenRouterHandler(mockOptions) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - id: "test-id", - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_openrouter_test", - function: { name: "read_file", arguments: '{"path":"test.ts"}' }, - }, - ], - }, - index: 0, - }, - ], - } - yield { - id: "test-id", - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - index: 0, - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - }, - } + const mockFullStream = (async function* () { + yield { type: "error", error: new Error("Stream error") } + })() - const mockCreate = vitest.fn().mockResolvedValue(mockStream) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0, totalTokens: 0 }), + totalUsage: Promise.resolve({ inputTokens: 0, outputTokens: 0, totalTokens: 0 }), + }) - const generator = handler.createMessage("test", []) + const generator = handler.createMessage("test", [{ role: "user", content: "test" }]) const chunks = [] for await (const chunk of generator) { - // Simulate what Task.ts does: when we receive tool_call_partial, - // process it through NativeToolCallParser to populate rawChunkTracker - if (chunk.type === "tool_call_partial") { - NativeToolCallParser.processRawChunk({ - index: chunk.index, - id: chunk.id, - name: chunk.name, - arguments: chunk.arguments, - }) - } chunks.push(chunk) } - // Should have tool_call_partial and tool_call_end - const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - - expect(partialChunks).toHaveLength(1) - expect(endChunks).toHaveLength(1) - expect(endChunks[0].id).toBe("call_openrouter_test") + expect(chunks[0]).toEqual({ + type: "error", + error: "StreamError", + message: "Stream error", + }) }) - }) - describe("completePrompt", () => { - it("returns correct response", async () => { + it("passes tools to streamText when provided", async () => { const handler = new OpenRouterHandler(mockOptions) - const mockResponse = { choices: [{ message: { content: "test completion" } }] } - const mockCreate = vitest.fn().mockResolvedValue(mockResponse) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + const mockFullStream = (async function* () { + yield { type: "text-delta", text: "test", id: "1" } + })() - const result = await handler.completePrompt("test prompt") - - expect(result).toBe("test completion") + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }), + totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }), + }) - expect(mockCreate).toHaveBeenCalledWith( + const tools = [ { - model: mockOptions.openRouterModelId, - max_tokens: 8192, - temperature: 0, - messages: [{ role: "user", content: "test prompt" }], - stream: false, - }, - { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }, - ) - }) - - it("handles API errors and captures telemetry", async () => { - const handler = new OpenRouterHandler(mockOptions) - const mockError = { - error: { - message: "API Error", - code: 500, + type: "function" as const, + function: { + name: "read_file", + description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" } } }, + }, }, - } + ] - const mockCreate = vitest.fn().mockResolvedValue(mockError) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + const generator = handler.createMessage("test", [{ role: "user", content: "test" }], { + taskId: "test", + tools, + }) - await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenRouter API Error 500: API Error") + for await (const _ of generator) { + // consume + } - // Verify telemetry was captured - expect(mockCaptureException).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - message: "API Error", - provider: "OpenRouter", - modelId: mockOptions.openRouterModelId, - operation: "completePrompt", - errorCode: 500, - status: 500, + tools: expect.objectContaining({ + read_file: expect.any(Object), + }), }), ) }) + }) - it("handles unexpected errors and captures telemetry", async () => { + describe("completePrompt", () => { + it("returns correct response", async () => { const handler = new OpenRouterHandler(mockOptions) - const error = new Error("Unexpected error") - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any - await expect(handler.completePrompt("test prompt")).rejects.toThrow("Unexpected error") + mockGenerateText.mockResolvedValue({ + text: "test completion", + }) + + const result = await handler.completePrompt("test prompt") - // Verify telemetry was captured (filtering now happens inside PostHogTelemetryClient) - expect(mockCaptureException).toHaveBeenCalledWith( + expect(result).toBe("test completion") + expect(mockGenerateText).toHaveBeenCalledWith( expect.objectContaining({ - message: "Unexpected error", - provider: "OpenRouter", - modelId: mockOptions.openRouterModelId, - operation: "completePrompt", + prompt: "test prompt", + maxOutputTokens: 8192, + temperature: 0, }), ) }) - it("passes SDK exceptions with status 429 to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + it("handles API errors", async () => { const handler = new OpenRouterHandler(mockOptions) - const error = new Error("Rate limit exceeded: free-models-per-day") as any - error.status = 429 - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any - await expect(handler.completePrompt("test prompt")).rejects.toThrow("Rate limit exceeded") + mockGenerateText.mockRejectedValue(new Error("API Error")) - // captureException is called, but PostHogTelemetryClient filters out 429 errors internally - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Rate limit exceeded: free-models-per-day", - provider: "OpenRouter", - modelId: mockOptions.openRouterModelId, - operation: "completePrompt", - }), + await expect(handler.completePrompt("test prompt")).rejects.toThrow( + "OpenRouter completion error: API Error", ) }) - it("passes SDK exceptions with 429 in message to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + it("handles rate limit errors", async () => { const handler = new OpenRouterHandler(mockOptions) - const error = new Error("429 Rate limit exceeded: free-models-per-day") - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any - await expect(handler.completePrompt("test prompt")).rejects.toThrow("429 Rate limit exceeded") + mockGenerateText.mockRejectedValue(new Error("Rate limit exceeded")) - // captureException is called, but PostHogTelemetryClient filters out 429 errors internally - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "429 Rate limit exceeded: free-models-per-day", - provider: "OpenRouter", - modelId: mockOptions.openRouterModelId, - operation: "completePrompt", - }), + await expect(handler.completePrompt("test prompt")).rejects.toThrow( + "OpenRouter completion error: Rate limit exceeded", ) }) + }) - it("passes SDK exceptions containing 'rate limit' to telemetry (filtering happens in PostHogTelemetryClient)", async () => { - const handler = new OpenRouterHandler(mockOptions) - const error = new Error("Request failed due to rate limit") - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + describe("provider configuration", () => { + it("creates OpenRouter provider with correct API key and base URL", async () => { + const customOptions: ApiHandlerOptions = { + openRouterApiKey: "custom-key", + openRouterBaseUrl: "https://custom.openrouter.ai/api/v1", + openRouterModelId: "anthropic/claude-sonnet-4", + } - await expect(handler.completePrompt("test prompt")).rejects.toThrow("rate limit") + const handler = new OpenRouterHandler(customOptions) - // captureException is called, but PostHogTelemetryClient filters out rate limit errors internally - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Request failed due to rate limit", - provider: "OpenRouter", - modelId: mockOptions.openRouterModelId, - operation: "completePrompt", - }), - ) + const mockFullStream = (async function* () { + yield { type: "text-delta", text: "test", id: "1" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }), + totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }), + }) + + const generator = handler.createMessage("test", [{ role: "user", content: "test" }]) + + for await (const _ of generator) { + // consume + } + + expect(mockCreateOpenRouter).toHaveBeenCalledWith({ + apiKey: "custom-key", + baseURL: "https://custom.openrouter.ai/api/v1", + }) }) - it("passes 429 rate limit errors from response to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + it("uses default base URL when not specified", async () => { const handler = new OpenRouterHandler(mockOptions) - const mockError = { - error: { - message: "Rate limit exceeded", - code: 429, - }, - } - const mockCreate = vitest.fn().mockResolvedValue(mockError) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + const mockFullStream = (async function* () { + yield { type: "text-delta", text: "test", id: "1" } + })() - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - "OpenRouter API Error 429: Rate limit exceeded", - ) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }), + totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }), + }) - // captureException is called, but PostHogTelemetryClient filters out 429 errors internally - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Rate limit exceeded", - provider: "OpenRouter", - modelId: mockOptions.openRouterModelId, - operation: "completePrompt", - errorCode: 429, - status: 429, - }), - ) + const generator = handler.createMessage("test", [{ role: "user", content: "test" }]) + + for await (const _ of generator) { + // consume + } + + expect(mockCreateOpenRouter).toHaveBeenCalledWith({ + apiKey: "test-key", + baseURL: "https://openrouter.ai/api/v1", + }) }) }) }) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 902b2f646b..f4b5171751 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -1,162 +1,43 @@ import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" -import { z } from "zod" +import { createOpenRouter } from "@openrouter/ai-sdk-provider" +import { streamText, generateText } from "ai" import { type ModelRecord, - ApiProviderError, openRouterDefaultModelId, openRouterDefaultModelInfo, + NATIVE_TOOL_DEFAULTS, OPENROUTER_DEFAULT_PROVIDER_NAME, - OPEN_ROUTER_PROMPT_CACHING_MODELS, DEEP_SEEK_DEFAULT_TEMPERATURE, } from "@roo-code/types" -import { TelemetryService } from "@roo-code/telemetry" - -import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser" import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToOpenAiMessages, - sanitizeGeminiMessages, - consolidateReasoningDetails, -} from "../transform/openai-format" -import { normalizeMistralToolCallId } from "../transform/mistral-format" -import { resolveToolProtocol } from "../../utils/resolveToolProtocol" -import { TOOL_PROTOCOL } from "@roo-code/types" -import { ApiStreamChunk } from "../transform/stream" -import { convertToR1Format } from "../transform/r1-format" -import { addCacheBreakpoints as addAnthropicCacheBreakpoints } from "../transform/caching/anthropic" -import { addCacheBreakpoints as addGeminiCacheBreakpoints } from "../transform/caching/gemini" -import type { OpenRouterReasoningParams } from "../transform/reasoning" -import { getModelParams } from "../transform/model-params" - -import { getModels } from "./fetchers/modelCache" -import { getModelEndpoints } from "./fetchers/modelEndpointCache" - -import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" -import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index" -import { handleOpenAIError } from "./utils/openai-error-handler" -import { generateImageWithProvider, ImageGenerationResult } from "./utils/image-generation" +import { getModels, getModelsFromCache } from "./fetchers/modelCache" +import { getModelEndpoints } from "./fetchers/modelEndpointCache" import { applyRouterToolPreferences } from "./utils/router-tool-preferences" +import { getModelParams } from "../transform/model-params" +import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } from "../transform/ai-sdk" +import { generateImageWithProvider, ImageGenerationResult } from "./utils/image-generation" -// Add custom interface for OpenRouter params. -type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { - transforms?: string[] - include_reasoning?: boolean - // https://openrouter.ai/docs/use-cases/reasoning-tokens - reasoning?: OpenRouterReasoningParams -} - -// Zod schema for OpenRouter error response structure (for caught exceptions) -const OpenRouterErrorResponseSchema = z.object({ - error: z - .object({ - message: z.string().optional(), - code: z.number().optional(), - metadata: z - .object({ - raw: z.string().optional(), - }) - .optional(), - }) - .optional(), -}) - -// OpenRouter error structure that may include error.metadata.raw with actual upstream error -// This is for caught exceptions which have the error wrapped in an "error" property -interface OpenRouterErrorResponse { - error?: { - message?: string - code?: number - metadata?: { raw?: string } - } -} - -// Direct error object structure (for streaming errors passed directly) -interface OpenRouterError { - message?: string - code?: number - metadata?: { raw?: string } -} +import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index" +import type { ApiStreamChunk } from "../transform/stream" /** - * Helper function to parse and extract error message from metadata.raw - * metadata.raw is often a JSON encoded string that may contain .message or .error fields - * Example structures: - * - {"message": "Error text"} - * - {"error": "Error text"} - * - {"error": {"message": "Error text"}} - * - {"type":"error","error":{"type":"invalid_request_error","message":"tools: Tool names must be unique."}} + * OpenRouter handler using the Vercel AI SDK. + * This provides a standardized interface following the AI SDK provider pattern. */ -function extractErrorFromMetadataRaw(raw: string | undefined): string | undefined { - if (!raw) { - return undefined - } - - try { - const parsed = JSON.parse(raw) - // Check for common error message fields - if (typeof parsed === "object" && parsed !== null) { - // Check for direct message field - if (typeof parsed.message === "string") { - return parsed.message - } - // Check for nested error.message field (e.g., Anthropic error format) - if (typeof parsed.error === "object" && parsed.error !== null && typeof parsed.error.message === "string") { - return parsed.error.message - } - // Check for error as a string - if (typeof parsed.error === "string") { - return parsed.error - } - } - // If we can't extract a specific field, return the raw string - return raw - } catch { - // If it's not valid JSON, return as-is - return raw - } -} - -// See `OpenAI.Chat.Completions.ChatCompletionChunk["usage"]` -// `CompletionsAPI.CompletionUsage` -// See also: https://openrouter.ai/docs/use-cases/usage-accounting -interface CompletionUsage { - completion_tokens?: number - completion_tokens_details?: { - reasoning_tokens?: number - } - prompt_tokens?: number - prompt_tokens_details?: { - cached_tokens?: number - } - total_tokens?: number - cost?: number - cost_details?: { - upstream_inference_cost?: number - } -} - export class OpenRouterHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: OpenAI protected models: ModelRecord = {} protected endpoints: ModelRecord = {} private readonly providerName = "OpenRouter" - private currentReasoningDetails: any[] = [] constructor(options: ApiHandlerOptions) { super() this.options = options - const baseURL = this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1" - const apiKey = this.options.openRouterApiKey ?? "not-provided" - - this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: DEFAULT_HEADERS }) - // Load models asynchronously to populate cache before getModel() is called this.loadDynamicModels().catch((error) => { console.error("[OpenRouterHandler] Failed to load dynamic models:", error) @@ -184,28 +65,17 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } } - getReasoningDetails(): any[] | undefined { - return this.currentReasoningDetails.length > 0 ? this.currentReasoningDetails : undefined - } - /** - * Handle OpenRouter streaming error response and report to telemetry. - * OpenRouter may include metadata.raw with the actual upstream provider error. - * @param error The error object (not wrapped - receives the error directly) + * Create the OpenRouter provider instance using the AI SDK */ - private handleStreamingError(error: OpenRouterError, modelId: string, operation: string): never { - const rawString = error?.metadata?.raw - const parsedError = extractErrorFromMetadataRaw(rawString) - const rawErrorMessage = parsedError || error?.message || "Unknown error" - - const apiError = Object.assign( - new ApiProviderError(rawErrorMessage, this.providerName, modelId, operation, error?.code), - { status: error?.code, error }, - ) - - TelemetryService.instance.captureException(apiError) + private createOpenRouterProvider() { + const apiKey = this.options.openRouterApiKey ?? "not-provided" + const baseURL = this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1" - throw new Error(`OpenRouter API Error ${error?.code}: ${rawErrorMessage}`) + return createOpenRouter({ + apiKey, + baseURL, + }) } override async *createMessage( @@ -214,326 +84,59 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH metadata?: ApiHandlerCreateMessageMetadata, ): AsyncGenerator { const model = await this.fetchModel() - - let { id: modelId, maxTokens, temperature, topP, reasoning } = model - - // Reset reasoning_details accumulator for this request - this.currentReasoningDetails = [] - - // OpenRouter sends reasoning tokens by default for Gemini 2.5 Pro models - // even if you don't request them. This is not the default for - // other providers (including Gemini), so we need to explicitly disable - // them unless the user has explicitly configured reasoning. - // Note: Gemini 3 models use reasoning_details format with thought signatures, - // but we handle this via skip_thought_signature_validator injection below. - if ( - (modelId === "google/gemini-2.5-pro-preview" || modelId === "google/gemini-2.5-pro") && - typeof reasoning === "undefined" - ) { - reasoning = { exclude: true } - } - - // Convert Anthropic messages to OpenAI format. - // Pass normalization function for Mistral compatibility (requires 9-char alphanumeric IDs) - const isMistral = modelId.toLowerCase().includes("mistral") - let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages( - messages, - isMistral ? { normalizeToolCallId: normalizeMistralToolCallId } : undefined, - ), - ] - - // DeepSeek highly recommends using user instead of system role. - if (modelId.startsWith("deepseek/deepseek-r1") || modelId === "perplexity/sonar-reasoning") { - openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) - } - - // Process reasoning_details when switching models to Gemini for native tool call compatibility - // IMPORTANT: Use metadata.toolProtocol if provided (task's locked protocol) for consistency - const toolProtocol = resolveToolProtocol(this.options, model.info, metadata?.toolProtocol) - const isNativeProtocol = toolProtocol === TOOL_PROTOCOL.NATIVE - const isGemini = modelId.startsWith("google/gemini") - - // For Gemini models with native protocol: - // 1. Sanitize messages to handle thought signature validation issues. - // This must happen BEFORE fake encrypted block injection to avoid injecting for - // tool calls that will be dropped due to missing/mismatched reasoning_details. - // 2. Inject fake reasoning.encrypted block for tool calls without existing encrypted reasoning. - // This is required when switching from other models to Gemini to satisfy API validation. - // Per OpenRouter documentation (conversation with Toven, Nov 2025): - // - Create ONE reasoning_details entry per assistant message with tool calls - // - Set `id` to the FIRST tool call's ID from the tool_calls array - // - Set `data` to "skip_thought_signature_validator" to bypass signature validation - // - Set `index` to 0 - // See: https://github.com/cline/cline/issues/8214 - if (isNativeProtocol && isGemini) { - // Step 1: Sanitize messages - filter out tool calls with missing/mismatched reasoning_details - openAiMessages = sanitizeGeminiMessages(openAiMessages, modelId) - - // Step 2: Inject fake reasoning.encrypted block for tool calls that survived sanitization - openAiMessages = openAiMessages.map((msg) => { - if (msg.role === "assistant") { - const toolCalls = (msg as any).tool_calls as any[] | undefined - const existingDetails = (msg as any).reasoning_details as any[] | undefined - - // Only inject if there are tool calls and no existing encrypted reasoning - if (toolCalls && toolCalls.length > 0) { - const hasEncrypted = existingDetails?.some((d) => d.type === "reasoning.encrypted") ?? false - - if (!hasEncrypted) { - // Create ONE fake encrypted block with the FIRST tool call's ID - // This is the documented format from OpenRouter for skipping thought signature validation - const fakeEncrypted = { - type: "reasoning.encrypted", - data: "skip_thought_signature_validator", - id: toolCalls[0].id, - format: "google-gemini-v1", - index: 0, - } - - return { - ...msg, - reasoning_details: [...(existingDetails ?? []), fakeEncrypted], - } - } + const { id: modelId, maxTokens, temperature } = model + + const openrouter = this.createOpenRouterProvider() + const coreMessages = convertToAiSdkMessages(messages) + const tools = convertToolsForAiSdk(metadata?.tools) + + // Build provider options for specific provider routing + const providerOptions = + this.options.openRouterSpecificProvider && + this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME + ? { + openrouter: { + provider: { + order: [this.options.openRouterSpecificProvider], + only: [this.options.openRouterSpecificProvider], + allow_fallbacks: false, + }, + }, } - } - return msg - }) - } + : undefined - // https://openrouter.ai/docs/features/prompt-caching - // TODO: Add a `promptCacheStratey` field to `ModelInfo`. - if (OPEN_ROUTER_PROMPT_CACHING_MODELS.has(modelId)) { - if (modelId.startsWith("google")) { - addGeminiCacheBreakpoints(systemPrompt, openAiMessages) - } else { - addAnthropicCacheBreakpoints(systemPrompt, openAiMessages) - } - } - - // https://openrouter.ai/docs/transforms - const completionParams: OpenRouterChatCompletionParams = { - model: modelId, - ...(maxTokens && maxTokens > 0 && { max_tokens: maxTokens }), - temperature, - top_p: topP, - messages: openAiMessages, - stream: true, - stream_options: { include_usage: true }, - // Only include provider if openRouterSpecificProvider is not "[default]". - ...(this.options.openRouterSpecificProvider && - this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME && { - provider: { - order: [this.options.openRouterSpecificProvider], - only: [this.options.openRouterSpecificProvider], - allow_fallbacks: false, - }, - }), - ...(reasoning && { reasoning }), - ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), - ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), - } - - // Add Anthropic beta header for fine-grained tool streaming when using Anthropic models - const requestOptions = modelId.startsWith("anthropic/") - ? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } } - : undefined - - let stream try { - stream = await this.client.chat.completions.create(completionParams, requestOptions) - } catch (error) { - // Try to parse as OpenRouter error structure using Zod - const parseResult = OpenRouterErrorResponseSchema.safeParse(error) - - if (parseResult.success && parseResult.data.error) { - const openRouterError = parseResult.data - const rawString = openRouterError.error?.metadata?.raw - const parsedError = extractErrorFromMetadataRaw(rawString) - const rawErrorMessage = parsedError || openRouterError.error?.message || "Unknown error" - - const apiError = Object.assign( - new ApiProviderError( - rawErrorMessage, - this.providerName, - modelId, - "createMessage", - openRouterError.error?.code, - ), - { - status: openRouterError.error?.code, - error: openRouterError.error, - }, - ) - - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) - } else { - // Fallback for non-OpenRouter errors - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) - } - } - - let lastUsage: CompletionUsage | undefined = undefined - // Accumulator for reasoning_details FROM the API. - // We preserve the original shape of reasoning_details to prevent malformed responses. - const reasoningDetailsAccumulator = new Map< - string, - { - type: string - text?: string - summary?: string - data?: string - id?: string | null - format?: string - signature?: string - index: number - } - >() - - // Track whether we've yielded displayable text from reasoning_details. - // When reasoning_details has displayable content (reasoning.text or reasoning.summary), - // we skip yielding the top-level reasoning field to avoid duplicate display. - let hasYieldedReasoningFromDetails = false - - for await (const chunk of stream) { - // OpenRouter returns an error object instead of the OpenAI SDK throwing an error. - if ("error" in chunk) { - this.handleStreamingError(chunk.error as OpenRouterError, modelId, "createMessage") - } - - const delta = chunk.choices[0]?.delta - const finishReason = chunk.choices[0]?.finish_reason - - if (delta) { - // Handle reasoning_details array format (used by Gemini 3, Claude, OpenAI o-series, etc.) - // See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks - // Priority: Check for reasoning_details first, as it's the newer format - const deltaWithReasoning = delta as typeof delta & { - reasoning_details?: Array<{ - type: string - text?: string - summary?: string - data?: string - id?: string | null - format?: string - signature?: string - index?: number - }> - } - - if (deltaWithReasoning.reasoning_details && Array.isArray(deltaWithReasoning.reasoning_details)) { - for (const detail of deltaWithReasoning.reasoning_details) { - const index = detail.index ?? 0 - const key = `${detail.type}-${index}` - const existing = reasoningDetailsAccumulator.get(key) - - if (existing) { - // Accumulate text/summary/data for existing reasoning detail - if (detail.text !== undefined) { - existing.text = (existing.text || "") + detail.text - } - if (detail.summary !== undefined) { - existing.summary = (existing.summary || "") + detail.summary - } - if (detail.data !== undefined) { - existing.data = (existing.data || "") + detail.data - } - // Update other fields if provided - if (detail.id !== undefined) existing.id = detail.id - if (detail.format !== undefined) existing.format = detail.format - if (detail.signature !== undefined) existing.signature = detail.signature - } else { - // Start new reasoning detail accumulation - reasoningDetailsAccumulator.set(key, { - type: detail.type, - text: detail.text, - summary: detail.summary, - data: detail.data, - id: detail.id, - format: detail.format, - signature: detail.signature, - index, - }) - } - - // Yield text for display (still fragmented for live streaming) - // Only reasoning.text and reasoning.summary have displayable content - // reasoning.encrypted is intentionally skipped as it contains redacted content - let reasoningText: string | undefined - if (detail.type === "reasoning.text" && typeof detail.text === "string") { - reasoningText = detail.text - } else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") { - reasoningText = detail.summary - } - - if (reasoningText) { - hasYieldedReasoningFromDetails = true - yield { type: "reasoning", text: reasoningText } - } - } - } - - // Handle top-level reasoning field for UI display. - // Skip if we've already yielded from reasoning_details to avoid duplicate display. - if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { - if (!hasYieldedReasoningFromDetails) { - yield { type: "reasoning", text: delta.reasoning } - } - } - - // Emit raw tool call chunks - NativeToolCallParser handles state management - if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - if (delta.content) { - yield { type: "text", text: delta.content } - } - } - - // Process finish_reason to emit tool_call_end events - // This ensures tool calls are finalized even if the stream doesn't properly close - if (finishReason) { - const endEvents = NativeToolCallParser.processFinishReason(finishReason) - for (const event of endEvents) { - yield event - } - } + const result = streamText({ + model: openrouter.chat(modelId), + system: systemPrompt, + messages: coreMessages, + maxOutputTokens: maxTokens && maxTokens > 0 ? maxTokens : undefined, + temperature, + tools, + toolChoice: metadata?.tool_choice as any, + providerOptions, + }) - if (chunk.usage) { - lastUsage = chunk.usage + // Process the full stream for all event types + for await (const part of result.fullStream) { + yield* processAiSdkStreamPart(part) } - } - // After streaming completes, consolidate and store reasoning_details from the API. - // This filters out corrupted encrypted blocks (missing `data`) and consolidates by index. - if (reasoningDetailsAccumulator.size > 0) { - const rawDetails = Array.from(reasoningDetailsAccumulator.values()) - this.currentReasoningDetails = consolidateReasoningDetails(rawDetails) - } + // After streaming completes, yield usage information + const usage = await result.usage + const totalUsage = await result.totalUsage - if (lastUsage) { yield { type: "usage", - inputTokens: lastUsage.prompt_tokens || 0, - outputTokens: lastUsage.completion_tokens || 0, - cacheReadTokens: lastUsage.prompt_tokens_details?.cached_tokens, - reasoningTokens: lastUsage.completion_tokens_details?.reasoning_tokens, - totalCost: (lastUsage.cost_details?.upstream_inference_cost || 0) + (lastUsage.cost || 0), + inputTokens: totalUsage.inputTokens ?? usage.inputTokens ?? 0, + outputTokens: totalUsage.outputTokens ?? usage.outputTokens ?? 0, + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + yield { + type: "error", + error: "OpenRouterError", + message: `${this.providerName} API Error: ${errorMessage}`, } } } @@ -556,15 +159,31 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH override getModel() { const id = this.options.openRouterModelId ?? openRouterDefaultModelId - let info = this.models[id] ?? openRouterDefaultModelInfo - // If a specific provider is requested, use the endpoint for that provider. + // First check instance models (populated by fetchModel) + let info = this.models[id] + + if (!info) { + // Fall back to global cache + const cachedModels = getModelsFromCache("openrouter") + if (cachedModels?.[id]) { + this.models = cachedModels + info = cachedModels[id] + } + } + + // If a specific provider is requested, use the endpoint for that provider if (this.options.openRouterSpecificProvider && this.endpoints[this.options.openRouterSpecificProvider]) { info = this.endpoints[this.options.openRouterSpecificProvider] } - // Apply tool preferences for models accessed through routers (OpenAI, Gemini) - info = applyRouterToolPreferences(id, info) + // Fall back to default if nothing found + if (!info) { + info = openRouterDefaultModelInfo + } + + // Apply tool preferences for models accessed through routers + info = applyRouterToolPreferences(id, { ...NATIVE_TOOL_DEFAULTS, ...info }) const isDeepSeekR1 = id.startsWith("deepseek/deepseek-r1") || id === "perplexity/sonar-reasoning" @@ -579,77 +198,40 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH return { id, info, topP: isDeepSeekR1 ? 0.95 : undefined, ...params } } - async completePrompt(prompt: string) { - let { id: modelId, maxTokens, temperature, reasoning } = await this.fetchModel() - - const completionParams: OpenRouterChatCompletionParams = { - model: modelId, - max_tokens: maxTokens, - temperature, - messages: [{ role: "user", content: prompt }], - stream: false, - // Only include provider if openRouterSpecificProvider is not "[default]". - ...(this.options.openRouterSpecificProvider && - this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME && { - provider: { - order: [this.options.openRouterSpecificProvider], - only: [this.options.openRouterSpecificProvider], - allow_fallbacks: false, - }, - }), - ...(reasoning && { reasoning }), - } - - // Add Anthropic beta header for fine-grained tool streaming when using Anthropic models - const requestOptions = modelId.startsWith("anthropic/") - ? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } } - : undefined - - let response + async completePrompt(prompt: string): Promise { + const { id: modelId, maxTokens, temperature } = await this.fetchModel() + + const openrouter = this.createOpenRouterProvider() + + // Build provider options for specific provider routing + const providerOptions = + this.options.openRouterSpecificProvider && + this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME + ? { + openrouter: { + provider: { + order: [this.options.openRouterSpecificProvider], + only: [this.options.openRouterSpecificProvider], + allow_fallbacks: false, + }, + }, + } + : undefined try { - response = await this.client.chat.completions.create(completionParams, requestOptions) - } catch (error) { - // Try to parse as OpenRouter error structure using Zod - const parseResult = OpenRouterErrorResponseSchema.safeParse(error) - - if (parseResult.success && parseResult.data.error) { - const openRouterError = parseResult.data - const rawString = openRouterError.error?.metadata?.raw - const parsedError = extractErrorFromMetadataRaw(rawString) - const rawErrorMessage = parsedError || openRouterError.error?.message || "Unknown error" - - const apiError = Object.assign( - new ApiProviderError( - rawErrorMessage, - this.providerName, - modelId, - "completePrompt", - openRouterError.error?.code, - ), - { - status: openRouterError.error?.code, - error: openRouterError.error, - }, - ) - - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) - } else { - // Fallback for non-OpenRouter errors - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) - } - } + const result = await generateText({ + model: openrouter.chat(modelId), + prompt, + maxOutputTokens: maxTokens && maxTokens > 0 ? maxTokens : undefined, + temperature, + providerOptions, + }) - if ("error" in response) { - this.handleStreamingError(response.error as OpenRouterError, modelId, "completePrompt") + return result.text + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error(`${this.providerName} completion error: ${errorMessage}`) } - - const completion = response as OpenAI.Chat.ChatCompletion - return completion.choices[0]?.message?.content || "" } /** diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts new file mode 100644 index 0000000000..bfaf277c3a --- /dev/null +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -0,0 +1,380 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" +import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } from "../ai-sdk" + +vitest.mock("ai", () => ({ + tool: vitest.fn((t) => t), + jsonSchema: vitest.fn((s) => s), +})) + +describe("AI SDK conversion utilities", () => { + describe("convertToAiSdkMessages", () => { + it("converts simple string messages", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there" }, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ role: "user", content: "Hello" }) + expect(result[1]).toEqual({ role: "assistant", content: "Hi there" }) + }) + + it("converts user messages with text content blocks", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text", text: "Hello world" }], + }, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "user", + content: [{ type: "text", text: "Hello world" }], + }) + }) + + it("converts user messages with image content", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "base64encodeddata", + }, + }, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { + type: "image", + image: "data:image/png;base64,base64encodeddata", + mimeType: "image/png", + }, + ], + }) + }) + + it("converts tool results into separate tool messages with resolved tool names", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_123", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_123", + content: "Tool result content", + }, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_123", + toolName: "read_file", + args: { path: "test.ts" }, + }, + ], + }) + expect(result[1]).toEqual({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_123", + toolName: "read_file", + output: { type: "text", value: "Tool result content" }, + }, + ], + }) + }) + + it("uses unknown_tool for tool results without matching tool call", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_orphan", + content: "Orphan result", + }, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_orphan", + toolName: "unknown_tool", + output: { type: "text", value: "Orphan result" }, + }, + ], + }) + }) + + it("converts assistant messages with tool use", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "text", text: "Let me read that file" }, + { + type: "tool_use", + id: "call_456", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "assistant", + content: [ + { type: "text", text: "Let me read that file" }, + { + type: "tool-call", + toolCallId: "call_456", + toolName: "read_file", + args: { path: "test.ts" }, + }, + ], + }) + }) + + it("handles empty assistant content", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [], + }, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "assistant", + content: [{ type: "text", text: "" }], + }) + }) + }) + + describe("convertToolsForAiSdk", () => { + it("returns undefined for empty tools", () => { + expect(convertToolsForAiSdk(undefined)).toBeUndefined() + expect(convertToolsForAiSdk([])).toBeUndefined() + }) + + it("converts function tools to AI SDK format", () => { + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file from disk", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path" }, + }, + required: ["path"], + }, + }, + }, + ] + + const result = convertToolsForAiSdk(tools) + + expect(result).toBeDefined() + expect(result!.read_file).toBeDefined() + expect(result!.read_file.description).toBe("Read a file from disk") + }) + + it("converts multiple tools", () => { + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "write_file", + description: "Write a file", + parameters: {}, + }, + }, + ] + + const result = convertToolsForAiSdk(tools) + + expect(result).toBeDefined() + expect(Object.keys(result!)).toHaveLength(2) + expect(result!.read_file).toBeDefined() + expect(result!.write_file).toBeDefined() + }) + }) + + describe("processAiSdkStreamPart", () => { + it("processes text-delta chunks", () => { + const part = { type: "text-delta" as const, id: "1", text: "Hello" } + const chunks = [...processAiSdkStreamPart(part)] + + expect(chunks).toHaveLength(1) + expect(chunks[0]).toEqual({ type: "text", text: "Hello" }) + }) + + it("processes reasoning-delta chunks", () => { + const part = { type: "reasoning-delta" as const, id: "1", text: "thinking..." } + const chunks = [...processAiSdkStreamPart(part)] + + expect(chunks).toHaveLength(1) + expect(chunks[0]).toEqual({ type: "reasoning", text: "thinking..." }) + }) + + it("processes tool-input-start chunks", () => { + const part = { type: "tool-input-start" as const, id: "call_1", toolName: "read_file" } + const chunks = [...processAiSdkStreamPart(part)] + + expect(chunks).toHaveLength(1) + expect(chunks[0]).toEqual({ type: "tool_call_start", id: "call_1", name: "read_file" }) + }) + + it("processes tool-input-delta chunks", () => { + const part = { type: "tool-input-delta" as const, id: "call_1", delta: '{"path":' } + const chunks = [...processAiSdkStreamPart(part)] + + expect(chunks).toHaveLength(1) + expect(chunks[0]).toEqual({ type: "tool_call_delta", id: "call_1", delta: '{"path":' }) + }) + + it("processes tool-input-end chunks", () => { + const part = { type: "tool-input-end" as const, id: "call_1" } + const chunks = [...processAiSdkStreamPart(part)] + + expect(chunks).toHaveLength(1) + expect(chunks[0]).toEqual({ type: "tool_call_end", id: "call_1" }) + }) + + it("processes complete tool-call chunks", () => { + const part = { + type: "tool-call" as const, + toolCallId: "call_1", + toolName: "read_file", + input: { path: "test.ts" }, + } + const chunks = [...processAiSdkStreamPart(part)] + + expect(chunks).toHaveLength(1) + expect(chunks[0]).toEqual({ + type: "tool_call", + id: "call_1", + name: "read_file", + arguments: '{"path":"test.ts"}', + }) + }) + + it("processes source chunks with URL", () => { + const part = { + type: "source" as const, + url: "https://example.com", + title: "Example Source", + } + const chunks = [...processAiSdkStreamPart(part as any)] + + expect(chunks).toHaveLength(1) + expect(chunks[0]).toEqual({ + type: "grounding", + sources: [ + { + title: "Example Source", + url: "https://example.com", + snippet: undefined, + }, + ], + }) + }) + + it("processes error chunks", () => { + const part = { type: "error" as const, error: new Error("Test error") } + const chunks = [...processAiSdkStreamPart(part)] + + expect(chunks).toHaveLength(1) + expect(chunks[0]).toEqual({ + type: "error", + error: "StreamError", + message: "Test error", + }) + }) + + it("ignores lifecycle events", () => { + const lifecycleEvents = [ + { type: "text-start" as const }, + { type: "text-end" as const }, + { type: "reasoning-start" as const }, + { type: "reasoning-end" as const }, + { type: "start-step" as const }, + { type: "finish-step" as const }, + { type: "start" as const }, + { type: "finish" as const }, + { type: "abort" as const }, + ] + + for (const event of lifecycleEvents) { + const chunks = [...processAiSdkStreamPart(event as any)] + expect(chunks).toHaveLength(0) + } + }) + }) +}) diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts new file mode 100644 index 0000000000..a8140ed403 --- /dev/null +++ b/src/api/transform/ai-sdk.ts @@ -0,0 +1,263 @@ +/** + * AI SDK conversion utilities for transforming between Anthropic/OpenAI formats and Vercel AI SDK formats. + * These utilities are designed to be reused across different AI SDK providers. + */ + +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" +import { tool as createTool, jsonSchema, type CoreMessage, type TextStreamPart } from "ai" +import type { ApiStreamChunk } from "./stream" + +/** + * Convert Anthropic messages to AI SDK CoreMessage format. + * Handles text, images, tool uses, and tool results. + * + * @param messages - Array of Anthropic message parameters + * @returns Array of AI SDK CoreMessage objects + */ +export function convertToAiSdkMessages(messages: Anthropic.Messages.MessageParam[]): CoreMessage[] { + const coreMessages: CoreMessage[] = [] + + // First pass: build a map of tool call IDs to tool names from assistant messages + const toolCallIdToName = new Map() + for (const message of messages) { + if (message.role === "assistant" && typeof message.content !== "string") { + for (const part of message.content) { + if (part.type === "tool_use") { + toolCallIdToName.set(part.id, part.name) + } + } + } + } + + for (const message of messages) { + if (typeof message.content === "string") { + coreMessages.push({ + role: message.role, + content: message.content, + }) + } else { + if (message.role === "user") { + const parts: Array< + { type: "text"; text: string } | { type: "image"; image: string; mimeType?: string } + > = [] + const toolResults: Array<{ + type: "tool-result" + toolCallId: string + toolName: string + output: { type: "text"; value: string } + }> = [] + + for (const part of message.content) { + if (part.type === "text") { + parts.push({ type: "text", text: part.text }) + } else if (part.type === "image") { + parts.push({ + type: "image", + image: `data:${part.source.media_type};base64,${part.source.data}`, + mimeType: part.source.media_type, + }) + } else if (part.type === "tool_result") { + // Convert tool results to string content + let content: string + if (typeof part.content === "string") { + content = part.content + } else { + content = + part.content + ?.map((c) => { + if (c.type === "text") return c.text + if (c.type === "image") return "(image)" + return "" + }) + .join("\n") ?? "" + } + // Look up the tool name from the tool call ID + const toolName = toolCallIdToName.get(part.tool_use_id) ?? "unknown_tool" + toolResults.push({ + type: "tool-result", + toolCallId: part.tool_use_id, + toolName, + output: { type: "text", value: content || "(empty)" }, + }) + } + } + + // Tool results must be in a separate tool message + if (toolResults.length > 0) { + coreMessages.push({ + role: "tool", + content: toolResults, + } as CoreMessage) + } + + // Add user message with remaining content + if (parts.length > 0) { + coreMessages.push({ + role: "user", + content: parts, + } as CoreMessage) + } + } else if (message.role === "assistant") { + const textParts: string[] = [] + const toolCalls: Array<{ + type: "tool-call" + toolCallId: string + toolName: string + args: unknown + }> = [] + + for (const part of message.content) { + if (part.type === "text") { + textParts.push(part.text) + } else if (part.type === "tool_use") { + toolCalls.push({ + type: "tool-call", + toolCallId: part.id, + toolName: part.name, + args: part.input, + }) + } + } + + const content: Array< + | { type: "text"; text: string } + | { type: "tool-call"; toolCallId: string; toolName: string; args: unknown } + > = [] + + if (textParts.length > 0) { + content.push({ type: "text", text: textParts.join("\n") }) + } + content.push(...toolCalls) + + coreMessages.push({ + role: "assistant", + content: content.length > 0 ? content : [{ type: "text", text: "" }], + } as CoreMessage) + } + } + } + + return coreMessages +} + +/** + * Convert OpenAI-style function tool definitions to AI SDK tool format. + * + * @param tools - Array of OpenAI tool definitions + * @returns Record of AI SDK tools keyed by tool name, or undefined if no tools + */ +export function convertToolsForAiSdk( + tools: OpenAI.Chat.ChatCompletionTool[] | undefined, +): Record> | undefined { + if (!tools || tools.length === 0) { + return undefined + } + + const toolSet: Record> = {} + + for (const t of tools) { + if (t.type === "function") { + toolSet[t.function.name] = createTool({ + description: t.function.description, + inputSchema: jsonSchema(t.function.parameters as any), + }) + } + } + + return toolSet +} + +/** + * Process a single AI SDK stream part and yield the appropriate ApiStreamChunk(s). + * This generator handles all TextStreamPart types and converts them to the + * ApiStreamChunk format used by the application. + * + * @param part - The AI SDK TextStreamPart to process + * @yields ApiStreamChunk objects corresponding to the stream part + */ +export function* processAiSdkStreamPart(part: TextStreamPart): Generator { + switch (part.type) { + case "text-delta": + yield { type: "text", text: part.text } + break + + case "reasoning-delta": + yield { type: "reasoning", text: part.text } + break + + case "tool-input-start": + yield { + type: "tool_call_start", + id: part.id, + name: part.toolName, + } + break + + case "tool-input-delta": + yield { + type: "tool_call_delta", + id: part.id, + delta: part.delta, + } + break + + case "tool-input-end": + yield { + type: "tool_call_end", + id: part.id, + } + break + + case "tool-call": + // Complete tool call - emit for compatibility + yield { + type: "tool_call", + id: part.toolCallId, + name: part.toolName, + arguments: typeof part.input === "string" ? part.input : JSON.stringify(part.input), + } + break + + case "source": + // Handle both URL and document source types + if ("url" in part) { + yield { + type: "grounding", + sources: [ + { + title: part.title || "Source", + url: part.url, + snippet: undefined, + }, + ], + } + } + break + + case "error": + yield { + type: "error", + error: "StreamError", + message: part.error instanceof Error ? part.error.message : String(part.error), + } + break + + // Ignore lifecycle events that don't need to yield chunks + case "text-start": + case "text-end": + case "reasoning-start": + case "reasoning-end": + case "start-step": + case "finish-step": + case "start": + case "finish": + case "abort": + case "file": + case "tool-result": + case "tool-error": + case "raw": + // These events don't need to be yielded + break + } +} diff --git a/src/package.json b/src/package.json index 7168303784..3f4f384fb7 100644 --- a/src/package.json +++ b/src/package.json @@ -528,9 +528,10 @@ "web-tree-sitter": "^0.25.6", "workerpool": "^9.2.0", "yaml": "^2.8.0", - "zod": "3.25.61" + "zod": "3.25.76" }, "devDependencies": { + "@openrouter/ai-sdk-provider": "^1.5.4", "@roo-code/build": "workspace:^", "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", @@ -555,6 +556,7 @@ "@types/vscode": "^1.84.0", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "3.3.2", + "ai": "^5.0.0", "esbuild-wasm": "^0.25.0", "execa": "^9.5.2", "glob": "^11.1.0",