diff --git a/.changeset/vast-pumas-move.md b/.changeset/vast-pumas-move.md new file mode 100644 index 00000000000..63511a2d2bf --- /dev/null +++ b/.changeset/vast-pumas-move.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Add improved support for Kimi 2.5 reasoning through AI SDK diff --git a/package.json b/package.json index e90d4ce0a9e..b030be43776 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,9 @@ "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", + "@sap-ai-sdk/prompt-registry>zod": "^4.0.0" } } } diff --git a/packages/types/package.json b/packages/types/package.json index 9bbcdb42a23..446b5273429 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/packages/types/src/providers/moonshot.ts b/packages/types/src/providers/moonshot.ts index ddf96b96aed..536f17db9bd 100644 --- a/packages/types/src/providers/moonshot.ts +++ b/packages/types/src/providers/moonshot.ts @@ -6,26 +6,6 @@ export type MoonshotModelId = keyof typeof moonshotModels export const moonshotDefaultModelId: MoonshotModelId = "kimi-k2-thinking" export const moonshotModels = { - // kilocode_change start - "kimi-k2.5": { - maxTokens: 32_000, - contextWindow: 262_144, // 256K - supportsImages: true, // Native multimodal - supportsPromptCache: true, - supportsNativeTools: true, - defaultToolProtocol: "native", - supportsTemperature: false, // Based on API specs - defaultTemperature: 1.0, // Default for thinking mode - supportsReasoningBudget: true, - supportsReasoningEffort: true, - preserveReasoning: true, - inputPrice: 0.6, // $0.60 per million (cache miss) - outputPrice: 3.0, // $3.00 per million - cacheWritesPrice: 0, - cacheReadsPrice: 0.1, // $0.10 per million (cache hit) - description: `Kimi K2.5 is Kimi's most versatile multimodal model with native vision support. Supports both thinking mode (default, temp=1.0) and instant mode (thinking disabled, temp=0.6). Features 256K context, vision understanding, and agent capabilities.`, - }, - // kilocode_change end "kimi-for-coding": { maxTokens: 32_000, contextWindow: 131_072, @@ -111,6 +91,19 @@ export const moonshotModels = { defaultTemperature: 1.0, description: `The kimi-k2-thinking model is a general-purpose agentic reasoning model developed by Moonshot AI. Thanks to its strength in deep reasoning and multi-turn tool use, it can solve even the hardest problems.`, }, + "kimi-k2.5": { + maxTokens: 16_384, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: true, + inputPrice: 0.6, // $0.60 per million tokens (cache miss) + outputPrice: 3.0, // $3.00 per million tokens + cacheReadsPrice: 0.1, // $0.10 per million tokens (cache hit) + supportsTemperature: true, + defaultTemperature: 1.0, + description: + "Kimi K2.5 is the latest generation of Moonshot AI's Kimi series, featuring improved reasoning capabilities and enhanced performance across diverse tasks.", + }, } as const satisfies Record export const MOONSHOT_DEFAULT_TEMPERATURE = 0.6 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81a294fd410..73f9de7471c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,8 @@ overrides: glob: '>=11.1.0' '@types/react': ^18.3.23 '@types/react-dom': ^18.3.5 + zod: 3.25.76 + '@sap-ai-sdk/prompt-registry>zod': ^4.0.0 importers: @@ -510,8 +512,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:^ @@ -625,8 +627,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:^ @@ -663,13 +665,13 @@ importers: dependencies: '@anthropic-ai/bedrock-sdk': specifier: ^0.26.0 - version: 0.26.0(zod@4.3.5) + version: 0.26.0(zod@3.25.76) '@anthropic-ai/sdk': specifier: ^0.71.2 - version: 0.71.2(zod@4.3.5) + version: 0.71.2(zod@3.25.76) '@anthropic-ai/vertex-sdk': specifier: ^0.14.0 - version: 0.14.0(zod@4.3.5) + version: 0.14.0(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': specifier: ^3.966.0 version: 3.971.0 @@ -678,7 +680,7 @@ importers: version: 3.971.0 '@google/genai': specifier: ^1.35.0 - version: 1.37.0(@modelcontextprotocol/sdk@1.25.2(hono@4.11.4)(zod@4.3.5)) + version: 1.37.0(@modelcontextprotocol/sdk@1.25.2(hono@4.11.4)(zod@3.25.76)) '@inquirer/prompts': specifier: ^8.2.0 version: 8.2.0(@types/node@25.0.9) @@ -696,7 +698,7 @@ importers: version: 1.11.0 '@modelcontextprotocol/sdk': specifier: ^1.25.2 - version: 1.25.2(hono@4.11.4)(zod@4.3.5) + version: 1.25.2(hono@4.11.4)(zod@3.25.76) '@qdrant/js-client-rest': specifier: ^1.16.2 version: 1.16.2(typescript@5.9.3) @@ -852,7 +854,7 @@ importers: version: 0.6.3 openai: specifier: ^6.16.0 - version: 6.16.0(ws@8.19.0)(zod@4.3.5) + version: 6.16.0(ws@8.19.0)(zod@3.25.76) os-name: specifier: ^6.1.0 version: 6.1.0 @@ -968,8 +970,8 @@ importers: specifier: ^2.8.2 version: 2.8.2 zod: - specifier: ^4.3.5 - version: 4.3.5 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@eslint/js': specifier: ^9.39.2 @@ -1535,8 +1537,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:^ @@ -1569,7 +1571,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': @@ -1641,7 +1643,7 @@ importers: specifier: ^5.12.2 version: 5.12.2(ws@8.19.0)(zod@3.25.76) zod: - specifier: ^3.25.61 + specifier: 3.25.76 version: 3.25.76 devDependencies: '@roo-code/config-eslint': @@ -1660,8 +1662,8 @@ importers: packages/core-schemas: dependencies: zod: - specifier: ^4.3.5 - version: 4.3.5 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@roo-code/config-eslint': specifier: workspace:^ @@ -1724,8 +1726,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:^ @@ -1783,8 +1785,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:^ @@ -1805,8 +1807,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:^ @@ -1864,7 +1866,7 @@ importers: version: 1.64.1 '@google/genai': specifier: ^1.29.1 - version: 1.29.1(@modelcontextprotocol/sdk@1.25.2(hono@4.11.4)(zod@3.25.61)) + version: 1.29.1(@modelcontextprotocol/sdk@1.25.2(hono@4.11.4)(zod@3.25.76)) '@kilocode/core-schemas': specifier: workspace:^ version: link:../packages/core-schemas @@ -1876,10 +1878,10 @@ 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.24.0 - version: 1.25.2(hono@4.11.4)(zod@3.25.61) + version: 1.25.2(hono@4.11.4)(zod@3.25.76) '@qdrant/js-client-rest': specifier: ^1.14.0 version: 1.14.0(typescript@5.9.3) @@ -2014,7 +2016,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 @@ -2145,9 +2147,15 @@ 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: + '@ai-sdk/openai-compatible': + specifier: ^1.0.0 + version: 1.0.31(zod@3.25.76) + '@openrouter/ai-sdk-provider': + specifier: ^2.0.4 + version: 2.1.1(ai@6.0.57(zod@3.25.76))(zod@3.25.76) '@roo-code/build': specifier: workspace:^ version: link:../packages/build @@ -2232,6 +2240,9 @@ importers: '@vscode/vsce': specifier: 3.3.2 version: 3.3.2 + ai: + specifier: ^6.0.0 + version: 6.0.57(zod@3.25.76) dotenv: specifier: ^16.4.7 version: 16.5.0 @@ -2276,7 +2287,7 @@ importers: version: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.19.4)(yaml@2.8.0) zod-to-ts: specifier: ^1.2.0 - version: 1.2.0(typescript@5.9.3)(zod@3.25.61) + version: 1.2.0(typescript@5.9.3)(zod@3.25.76) webview-ui: dependencies: @@ -2497,8 +2508,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:^ @@ -2596,6 +2607,38 @@ packages: '@adobe/css-tools@4.4.2': resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==} + '@ai-sdk/gateway@3.0.25': + resolution: {integrity: sha512-j0AQeA7hOVqwImykQlganf/Euj3uEXf0h3G0O4qKTDpEwE+EZGIPnVimCWht5W91lAetPZSfavDyvfpuPDd2PQ==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/openai-compatible@1.0.31': + resolution: {integrity: sha512-znBvaVHM0M6yWNerIEy3hR+O8ZK2sPcE7e2cxfb6kYLEX3k//JH5VDnRnajseVofg7LXtTCFFdjsB7WLf1BdeQ==} + 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-utils@4.0.10': + resolution: {integrity: sha512-VeDAiCH+ZK8Xs4hb9Cw7pHlujWNL52RKe8TExOkrw6Ir1AmfajBZTb9XUdKOZO08RwQElIKA8+Ltm+Gqfo8djQ==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/provider@2.0.1': + resolution: {integrity: sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==} + engines: {node: '>=18'} + + '@ai-sdk/provider@3.0.5': + resolution: {integrity: sha512-2Xmoq6DBJqmSl80U6V9z5jJSJP7ehaJJQMy2iFUqTay06wdCqTnPVBBQbtEL8RCChenL+q5DC5H5WzU3vV3v8w==} + engines: {node: '>=18'} + '@alcalzone/ansi-tokenize@0.2.3': resolution: {integrity: sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==} engines: {node: '>=18'} @@ -2628,7 +2671,7 @@ packages: resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==} hasBin: true peerDependencies: - zod: ^3.25.0 || ^4.0.0 + zod: 3.25.76 peerDependenciesMeta: zod: optional: true @@ -4629,7 +4672,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==} @@ -4639,7 +4682,7 @@ packages: engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 + zod: 3.25.76 peerDependenciesMeta: '@cfworker/json-schema': optional: true @@ -5007,6 +5050,13 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@openrouter/ai-sdk-provider@2.1.1': + resolution: {integrity: sha512-UypPbVnSExxmG/4Zg0usRiit3auvQVrjUXSyEhm0sZ9GQnW/d8p/bKgCk2neh1W5YyRSo7PNQvCrAEBHZnqQkQ==} + engines: {node: '>=18'} + peerDependencies: + ai: ^6.0.0 + zod: 3.25.76 + '@opentelemetry/api-logs@0.208.0': resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} engines: {node: '>=8.0.0'} @@ -8021,6 +8071,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} @@ -8452,6 +8506,12 @@ packages: resolution: {integrity: sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w==} engines: {node: '>=12'} + ai@6.0.57: + resolution: {integrity: sha512-5wYcMQmOaNU71wGv4XX1db3zvn4uLjLbTKIo6cQZPWOJElA0882XI7Eawx6TCd5jbjOvKMIP+KLWbpVomAFT2g==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -11085,6 +11145,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'} @@ -13160,6 +13224,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -14783,7 +14850,7 @@ packages: hasBin: true peerDependencies: ws: ^8.18.0 - zod: ^3.23.8 + zod: 3.25.76 peerDependenciesMeta: ws: optional: true @@ -14795,7 +14862,7 @@ packages: hasBin: true peerDependencies: ws: ^8.18.0 - zod: ^3.25 || ^4.0 + zod: 3.25.76 peerDependenciesMeta: ws: optional: true @@ -18940,36 +19007,30 @@ 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-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: - zod: ^3.25 || ^4 + 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==} - zod@4.3.5: - resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} zustand@5.0.9: resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} @@ -18998,6 +19059,41 @@ snapshots: '@adobe/css-tools@4.4.2': {} + '@ai-sdk/gateway@3.0.25(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.5 + '@ai-sdk/provider-utils': 4.0.10(zod@3.25.76) + '@vercel/oidc': 3.1.0 + zod: 3.25.76 + + '@ai-sdk/openai-compatible@1.0.31(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.1 + '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) + 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-utils@4.0.10(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.5 + '@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 + + '@ai-sdk/provider@3.0.5': + dependencies: + json-schema: 0.4.0 + '@alcalzone/ansi-tokenize@0.2.3': dependencies: ansi-styles: 6.2.3 @@ -19033,9 +19129,9 @@ snapshots: transitivePeerDependencies: - aws-crt - '@anthropic-ai/bedrock-sdk@0.26.0(zod@4.3.5)': + '@anthropic-ai/bedrock-sdk@0.26.0(zod@3.25.76)': dependencies: - '@anthropic-ai/sdk': 0.71.2(zod@4.3.5) + '@anthropic-ai/sdk': 0.71.2(zod@3.25.76) '@aws-crypto/sha256-js': 4.0.0 '@aws-sdk/client-bedrock-runtime': 3.971.0 '@aws-sdk/credential-providers': 3.971.0 @@ -19052,11 +19148,11 @@ snapshots: '@anthropic-ai/sdk@0.51.0': {} - '@anthropic-ai/sdk@0.71.2(zod@4.3.5)': + '@anthropic-ai/sdk@0.71.2(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: - zod: 4.3.5 + zod: 3.25.76 '@anthropic-ai/vertex-sdk@0.11.5': dependencies: @@ -19066,9 +19162,9 @@ snapshots: - encoding - supports-color - '@anthropic-ai/vertex-sdk@0.14.0(zod@4.3.5)': + '@anthropic-ai/vertex-sdk@0.14.0(zod@3.25.76)': dependencies: - '@anthropic-ai/sdk': 0.71.2(zod@4.3.5) + '@anthropic-ai/sdk': 0.71.2(zod@3.25.76) google-auth-library: 9.15.1 transitivePeerDependencies: - encoding @@ -21094,24 +21190,24 @@ snapshots: '@floating-ui/utils@0.2.9': {} - '@google/genai@1.29.1(@modelcontextprotocol/sdk@1.25.2(hono@4.11.4)(zod@3.25.61))': + '@google/genai@1.29.1(@modelcontextprotocol/sdk@1.25.2(hono@4.11.4)(zod@3.25.76))': dependencies: google-auth-library: 10.5.0 ws: 8.18.3 optionalDependencies: - '@modelcontextprotocol/sdk': 1.25.2(hono@4.11.4)(zod@3.25.61) + '@modelcontextprotocol/sdk': 1.25.2(hono@4.11.4)(zod@3.25.76) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@google/genai@1.37.0(@modelcontextprotocol/sdk@1.25.2(hono@4.11.4)(zod@4.3.5))': + '@google/genai@1.37.0(@modelcontextprotocol/sdk@1.25.2(hono@4.11.4)(zod@3.25.76))': dependencies: google-auth-library: 10.5.0 protobufjs: 7.5.4 ws: 8.18.3 optionalDependencies: - '@modelcontextprotocol/sdk': 1.25.2(hono@4.11.4)(zod@4.3.5) + '@modelcontextprotocol/sdk': 1.25.2(hono@4.11.4)(zod@3.25.76) transitivePeerDependencies: - bufferutil - supports-color @@ -22046,14 +22142,14 @@ snapshots: zod: 3.25.76 zod-to-json-schema: 3.24.5(zod@3.25.76) - '@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': {} - '@modelcontextprotocol/sdk@1.25.2(hono@4.11.4)(zod@3.25.61)': + '@modelcontextprotocol/sdk@1.25.2(hono@4.11.4)(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.9(hono@4.11.4) ajv: 8.17.1 @@ -22069,30 +22165,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.0 - zod: 3.25.61 - zod-to-json-schema: 3.25.1(zod@3.25.61) - transitivePeerDependencies: - - hono - - supports-color - - '@modelcontextprotocol/sdk@1.25.2(hono@4.11.4)(zod@4.3.5)': - dependencies: - '@hono/node-server': 1.19.9(hono@4.11.4) - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - content-type: 1.0.5 - cors: 2.8.5 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.2 - express: 5.1.0 - express-rate-limit: 7.5.0(express@5.1.0) - jose: 6.1.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.0 - zod: 4.3.5 - zod-to-json-schema: 3.25.1(zod@4.3.5) + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - hono - supports-color @@ -22380,6 +22454,11 @@ snapshots: '@open-draft/until@2.1.0': {} + '@openrouter/ai-sdk-provider@2.1.1(ai@6.0.57(zod@3.25.76))(zod@3.25.76)': + dependencies: + ai: 6.0.57(zod@3.25.76) + zod: 3.25.76 + '@opentelemetry/api-logs@0.208.0': dependencies: '@opentelemetry/api': 1.9.0 @@ -23483,7 +23562,7 @@ snapshots: '@sap-ai-sdk/prompt-registry@2.5.0': dependencies: '@sap-ai-sdk/core': 2.5.0 - zod: 4.3.5 + zod: 4.3.6 transitivePeerDependencies: - debug - supports-color @@ -25898,6 +25977,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.5(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.27.1 @@ -26533,6 +26614,14 @@ snapshots: clean-stack: 4.2.0 indent-string: 5.0.0 + ai@6.0.57(zod@3.25.76): + dependencies: + '@ai-sdk/gateway': 3.0.25(zod@3.25.76) + '@ai-sdk/provider': 3.0.5 + '@ai-sdk/provider-utils': 4.0.10(zod@3.25.76) + '@opentelemetry/api': 1.9.0 + zod: 3.25.76 + ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -27518,7 +27607,7 @@ snapshots: dependencies: devtools-protocol: 0.0.1367902 mitt: 3.0.1 - zod: 3.23.8 + zod: 3.25.76 chromium-bidi@12.0.1(devtools-protocol@0.0.1534754): dependencies: @@ -29406,6 +29495,8 @@ snapshots: eventsource-parser@3.0.2: {} + eventsource-parser@3.0.6: {} + eventsource@3.0.7: dependencies: eventsource-parser: 3.0.2 @@ -32115,6 +32206,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stable-stringify@1.3.0: @@ -34134,20 +34227,20 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@5.12.2(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 - zod: 3.25.61 + zod: 3.25.76 openai@5.12.2(ws@8.19.0)(zod@3.25.76): optionalDependencies: ws: 8.19.0 zod: 3.25.76 - openai@6.16.0(ws@8.19.0)(zod@4.3.5): + openai@6.16.0(ws@8.19.0)(zod@3.25.76): optionalDependencies: ws: 8.19.0 - zod: 4.3.5 + zod: 3.25.76 opentype.js@0.8.0: dependencies: @@ -39272,38 +39365,26 @@ 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-json-schema@3.25.1(zod@3.25.61): - dependencies: - zod: 3.25.61 - - zod-to-json-schema@3.25.1(zod@4.3.5): + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: - zod: 4.3.5 + zod: 3.25.76 - zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.61): + zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76): dependencies: typescript: 5.9.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: {} - zod@4.3.5: {} + zod@4.3.6: {} zustand@5.0.9(@types/react@18.3.23)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): optionalDependencies: diff --git a/src/api/providers/__tests__/moonshot.spec.ts b/src/api/providers/__tests__/moonshot.spec.ts index e87d49cc3fe..4493ea3cd20 100644 --- a/src/api/providers/__tests__/moonshot.spec.ts +++ b/src/api/providers/__tests__/moonshot.spec.ts @@ -1,67 +1,28 @@ -// Mocks must come first, before imports -const mockCreate = vi.fn() -vi.mock("openai", () => { +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - __esModule: true, - default: vi.fn().mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate.mockImplementation(async (options) => { - if (!options.stream) { - return { - id: "test-completion", - choices: [ - { - message: { role: "assistant", content: "Test response", refusal: null }, - finish_reason: "stop", - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - cached_tokens: 2, - }, - } - } - - // Return async iterator for streaming - return { - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - cached_tokens: 2, - }, - } - }, - } - }), - }, - }, - })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -import OpenAI from "openai" +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "moonshot-chat", + provider: "moonshot", + })) + }), +})) + import type { Anthropic } from "@anthropic-ai/sdk" import { moonshotDefaultModelId } from "@roo-code/types" @@ -90,15 +51,6 @@ describe("MoonshotHandler", () => { expect(handler.getModel().id).toBe(mockOptions.apiModelId) }) - it.skip("should throw error if API key is missing", () => { - expect(() => { - new MoonshotHandler({ - ...mockOptions, - moonshotApiKey: undefined, - }) - }).toThrow("Moonshot API key is required") - }) - it("should use default model ID if not provided", () => { const handlerWithoutModel = new MoonshotHandler({ ...mockOptions, @@ -113,12 +65,6 @@ describe("MoonshotHandler", () => { moonshotBaseUrl: undefined, }) expect(handlerWithoutBaseUrl).toBeInstanceOf(MoonshotHandler) - // The base URL is passed to OpenAI client internally - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://api.moonshot.ai/v1", - }), - ) }) it("should use chinese base URL if provided", () => { @@ -128,18 +74,6 @@ describe("MoonshotHandler", () => { moonshotBaseUrl: customBaseUrl, }) expect(handlerWithCustomUrl).toBeInstanceOf(MoonshotHandler) - // The custom base URL is passed to OpenAI client - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: customBaseUrl, - }), - ) - }) - - it("should set includeMaxTokens to true", () => { - // Create a new handler and verify OpenAI client was called with includeMaxTokens - const _handler = new MoonshotHandler(mockOptions) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: mockOptions.moonshotApiKey })) }) }) @@ -151,7 +85,7 @@ describe("MoonshotHandler", () => { expect(model.info.maxTokens).toBe(16000) expect(model.info.contextWindow).toBe(262144) expect(model.info.supportsImages).toBe(false) - expect(model.info.supportsPromptCache).toBe(true) // Should be true now + expect(model.info.supportsPromptCache).toBe(true) }) it("should return provided model ID with default model info if model does not exist", () => { @@ -162,11 +96,8 @@ describe("MoonshotHandler", () => { const model = handlerWithInvalidModel.getModel() expect(model.id).toBe("invalid-model") // Returns provided ID expect(model.info).toBeDefined() - // With the current implementation, it's the same object reference when using default model info - expect(model.info).toBe(handler.getModel().info) - // Should have the same base properties + // Should have the same base properties as default model expect(model.info.contextWindow).toBe(handler.getModel().info.contextWindow) - // And should have supportsPromptCache set to true expect(model.info.supportsPromptCache).toBe(true) }) @@ -203,6 +134,24 @@ describe("MoonshotHandler", () => { ] it("should handle streaming responses", async () => { + // Mock the fullStream async generator + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + // Mock usage promise + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: { cachedInputTokens: undefined }, + raw: { cached_tokens: 2 }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { @@ -216,6 +165,22 @@ describe("MoonshotHandler", () => { }) it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: { cached_tokens: 2 }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { @@ -229,6 +194,22 @@ describe("MoonshotHandler", () => { }) it("should include cache metrics in usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: { cached_tokens: 2 }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { @@ -242,6 +223,23 @@ describe("MoonshotHandler", () => { }) }) + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) + }) + describe("processUsageMetrics", () => { it("should correctly process usage metrics including cache information", () => { // We need to access the protected method, so we'll create a test subclass @@ -254,10 +252,12 @@ describe("MoonshotHandler", () => { const testHandler = new TestMoonshotHandler(mockOptions) const usage = { - prompt_tokens: 100, - completion_tokens: 50, - total_tokens: 150, - cached_tokens: 20, + inputTokens: 100, + outputTokens: 50, + details: {}, + raw: { + cached_tokens: 20, + }, } const result = testHandler.testProcessUsageMetrics(usage) @@ -279,10 +279,10 @@ describe("MoonshotHandler", () => { const testHandler = new TestMoonshotHandler(mockOptions) const usage = { - prompt_tokens: 100, - completion_tokens: 50, - total_tokens: 150, - // No cached_tokens + inputTokens: 100, + outputTokens: 50, + details: {}, + raw: {}, } const result = testHandler.testProcessUsageMetrics(usage) @@ -295,31 +295,25 @@ describe("MoonshotHandler", () => { }) }) - describe("addMaxTokensIfNeeded", () => { - it("should always add max_tokens regardless of includeMaxTokens option", () => { - // Create a test subclass to access the protected method + describe("getMaxOutputTokens", () => { + it("should return maxTokens from model info", () => { class TestMoonshotHandler extends MoonshotHandler { - public testAddMaxTokensIfNeeded(requestOptions: any, modelInfo: any) { - this.addMaxTokensIfNeeded(requestOptions, modelInfo) + public testGetMaxOutputTokens() { + return this.getMaxOutputTokens() } } const testHandler = new TestMoonshotHandler(mockOptions) - const requestOptions: any = {} - const modelInfo = { - maxTokens: 32_000, - } - - // Test with includeMaxTokens set to false - should still add max tokens - testHandler.testAddMaxTokensIfNeeded(requestOptions, modelInfo) + const result = testHandler.testGetMaxOutputTokens() - expect(requestOptions.max_tokens).toBe(32_000) + // Default model maxTokens is 16000 (kimi-k2-thinking) + expect(result).toBe(16000) }) it("should use modelMaxTokens when provided", () => { class TestMoonshotHandler extends MoonshotHandler { - public testAddMaxTokensIfNeeded(requestOptions: any, modelInfo: any) { - this.addMaxTokensIfNeeded(requestOptions, modelInfo) + public testGetMaxOutputTokens() { + return this.getMaxOutputTokens() } } @@ -328,32 +322,153 @@ describe("MoonshotHandler", () => { ...mockOptions, modelMaxTokens: customMaxTokens, }) - const requestOptions: any = {} - const modelInfo = { - maxTokens: 32_000, - } - testHandler.testAddMaxTokensIfNeeded(requestOptions, modelInfo) - - expect(requestOptions.max_tokens).toBe(customMaxTokens) + const result = testHandler.testGetMaxOutputTokens() + expect(result).toBe(customMaxTokens) }) it("should fall back to modelInfo.maxTokens when modelMaxTokens is not provided", () => { class TestMoonshotHandler extends MoonshotHandler { - public testAddMaxTokensIfNeeded(requestOptions: any, modelInfo: any) { - this.addMaxTokensIfNeeded(requestOptions, modelInfo) + public testGetMaxOutputTokens() { + return this.getMaxOutputTokens() } } const testHandler = new TestMoonshotHandler(mockOptions) - const requestOptions: any = {} - const modelInfo = { - maxTokens: 16_000, + const result = testHandler.testGetMaxOutputTokens() + + // moonshot-chat is not present in moonshotModels and falls back to default model info (maxTokens 16000) + expect(result).toBe(16000) + }) + }) + + describe("tool handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle tool calls in streaming", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-call-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"path":"test.ts"}', + } + yield { + type: "tool-input-end", + id: "tool-call-1", + } } - testHandler.testAddMaxTokensIfNeeded(requestOptions, modelInfo) + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: {}, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].id).toBe("tool-call-1") + expect(toolCallStartChunks[0].name).toBe("read_file") + + expect(toolCallDeltaChunks.length).toBe(1) + expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') + + expect(toolCallEndChunks.length).toBe(1) + expect(toolCallEndChunks[0].id).toBe("tool-call-1") + }) + + it("should handle complete tool calls", async () => { + async function* mockFullStream() { + yield { + type: "tool-call", + toolCallId: "tool-call-1", + toolName: "read_file", + input: { path: "test.ts" }, + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: {}, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - expect(requestOptions.max_tokens).toBe(16_000) + const toolCallChunks = chunks.filter((c) => c.type === "tool_call") + expect(toolCallChunks.length).toBe(1) + expect(toolCallChunks[0].id).toBe("tool-call-1") + expect(toolCallChunks[0].name).toBe("read_file") + expect(toolCallChunks[0].arguments).toBe('{"path":"test.ts"}') }) }) }) diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 6ca6421a1a8..9bca16de604 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -22,6 +22,8 @@ export { OpenAiCodexHandler } from "./openai-codex" export { OpenAiNativeHandler } from "./openai-native" export { OpenAiHandler } from "./openai" export { OpenAiCompatibleResponsesHandler } from "./openai-responses" // kilocode_change +export { OpenAICompatibleHandler } from "./openai-compatible" +export type { OpenAICompatibleConfig } from "./openai-compatible" export { OpenRouterHandler } from "./openrouter" export { QwenCodeHandler } from "./qwen-code" export { RequestyHandler } from "./requesty" diff --git a/src/api/providers/moonshot.ts b/src/api/providers/moonshot.ts index d29a10a3b3e..f7a849cc025 100644 --- a/src/api/providers/moonshot.ts +++ b/src/api/providers/moonshot.ts @@ -1,4 +1,3 @@ -import OpenAI from "openai" import { moonshotModels, moonshotDefaultModelId, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" @@ -6,18 +5,25 @@ import type { ApiHandlerOptions } from "../../shared/api" import type { ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { OpenAiHandler } from "./openai" +import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "./openai-compatible" -export class MoonshotHandler extends OpenAiHandler { +export class MoonshotHandler extends OpenAICompatibleHandler { constructor(options: ApiHandlerOptions) { - super({ - ...options, - openAiApiKey: options.moonshotApiKey ?? "not-provided", - openAiModelId: options.apiModelId ?? moonshotDefaultModelId, - openAiBaseUrl: options.moonshotBaseUrl ?? "https://api.moonshot.ai/v1", - openAiStreamingEnabled: true, - includeMaxTokens: true, - }) + const modelId = options.apiModelId ?? moonshotDefaultModelId + const modelInfo = + moonshotModels[modelId as keyof typeof moonshotModels] || moonshotModels[moonshotDefaultModelId] + + const config: OpenAICompatibleConfig = { + providerName: "moonshot", + baseURL: options.moonshotBaseUrl ?? "https://api.moonshot.ai/v1", + apiKey: options.moonshotApiKey ?? "not-provided", + modelId, + modelInfo, + modelMaxTokens: options.modelMaxTokens ?? undefined, + temperature: options.modelTemperature ?? undefined, + } + + super(options, config) } override getModel() { @@ -27,25 +33,38 @@ export class MoonshotHandler extends OpenAiHandler { return { id, info, ...params } } - // Override to handle Moonshot's usage metrics, including caching. - protected override processUsageMetrics(usage: any): ApiStreamUsageChunk { + /** + * Override to handle Moonshot's usage metrics, including caching. + * Moonshot returns cached_tokens in a different location than standard OpenAI. + */ + protected override processUsageMetrics(usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + raw?: Record + }): ApiStreamUsageChunk { + // Moonshot uses cached_tokens at the top level of raw usage data + const rawUsage = usage.raw as { cached_tokens?: number } | undefined + return { type: "usage", - inputTokens: usage?.prompt_tokens || 0, - outputTokens: usage?.completion_tokens || 0, + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, cacheWriteTokens: 0, - cacheReadTokens: usage?.cached_tokens, + cacheReadTokens: rawUsage?.cached_tokens ?? usage.details?.cachedInputTokens, } } - // Override to always include max_tokens for Moonshot (not max_completion_tokens) - protected override addMaxTokensIfNeeded( - requestOptions: - | OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming - | OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming, - modelInfo: ModelInfo, - ): void { - // Moonshot uses max_tokens instead of max_completion_tokens - requestOptions.max_tokens = this.options.modelMaxTokens || modelInfo.maxTokens + /** + * Override to always include max_tokens for Moonshot (not max_completion_tokens). + * Moonshot requires max_tokens parameter to be sent. + */ + protected override getMaxOutputTokens(): number | undefined { + const modelInfo = this.config.modelInfo + // Moonshot always requires max_tokens + return this.options.modelMaxTokens || modelInfo.maxTokens || undefined } } diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts new file mode 100644 index 00000000000..d129e72452f --- /dev/null +++ b/src/api/providers/openai-compatible.ts @@ -0,0 +1,212 @@ +/** + * OpenAI-compatible provider base class using Vercel AI SDK. + * This provides a parallel implementation to OpenAiHandler using @ai-sdk/openai-compatible. + */ + +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { streamText, generateText, LanguageModel, ToolSet } from "ai" + +import type { ModelInfo } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../shared/api" + +import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } from "../transform/ai-sdk" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" + +import { DEFAULT_HEADERS } from "./constants" +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" + +/** + * Configuration options for creating an OpenAI-compatible provider. + */ +export interface OpenAICompatibleConfig { + /** Provider name for identification */ + providerName: string + /** Base URL for the API endpoint */ + baseURL: string + /** API key for authentication */ + apiKey: string + /** Model ID to use */ + modelId: string + /** Model information */ + modelInfo: ModelInfo + /** Optional custom headers */ + headers?: Record + /** Whether to include max_tokens in requests (default: false uses max_completion_tokens) */ + useMaxTokens?: boolean + /** User-configured max tokens override */ + modelMaxTokens?: number + /** Temperature setting */ + temperature?: number +} + +/** + * Base class for OpenAI-compatible API providers using Vercel AI SDK. + * Extends BaseProvider and implements SingleCompletionHandler. + */ +export abstract class OpenAICompatibleHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + protected config: OpenAICompatibleConfig + protected provider: ReturnType + + constructor(options: ApiHandlerOptions, config: OpenAICompatibleConfig) { + super() + this.options = options + this.config = config + + // Create the OpenAI-compatible provider using AI SDK + this.provider = createOpenAICompatible({ + name: config.providerName, + baseURL: config.baseURL, + apiKey: config.apiKey, + headers: { + ...DEFAULT_HEADERS, + ...(config.headers || {}), + }, + }) + } + + /** + * Get the language model for the configured model ID. + */ + protected getLanguageModel(): LanguageModel { + return this.provider(this.config.modelId) + } + + /** + * Get the model information. Must be implemented by subclasses. + */ + abstract override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } + + /** + * Process usage metrics from the AI SDK response. + * Can be overridden by subclasses to handle provider-specific usage formats. + */ + protected processUsageMetrics(usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + raw?: Record + }): ApiStreamUsageChunk { + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens: usage.details?.cachedInputTokens, + reasoningTokens: usage.details?.reasoningTokens, + } + } + + /** + * Map OpenAI tool_choice to AI SDK toolChoice format. + */ + protected mapToolChoice( + toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"], + ): "auto" | "none" | "required" | { type: "tool"; toolName: string } | undefined { + if (!toolChoice) { + return undefined + } + + // Handle string values + if (typeof toolChoice === "string") { + switch (toolChoice) { + case "auto": + return "auto" + case "none": + return "none" + case "required": + return "required" + default: + return "auto" + } + } + + // Handle object values (OpenAI ChatCompletionNamedToolChoice format) + if (typeof toolChoice === "object" && "type" in toolChoice) { + if (toolChoice.type === "function" && "function" in toolChoice && toolChoice.function?.name) { + return { type: "tool", toolName: toolChoice.function.name } + } + } + + return undefined + } + + /** + * Get the max tokens parameter to include in the request. + */ + protected getMaxOutputTokens(): number | undefined { + const modelInfo = this.config.modelInfo + const maxTokens = this.config.modelMaxTokens || modelInfo.maxTokens + + return maxTokens ?? undefined + } + + /** + * Create a message stream using the AI SDK. + */ + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const model = this.getModel() + const languageModel = this.getLanguageModel() + + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) + + // Convert tools to OpenAI format first, then to AI SDK format + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build the request options + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: model.temperature ?? this.config.temperature ?? 0, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: this.mapToolChoice(metadata?.tool_choice), + } + + // Use streamText for streaming responses + const result = streamText(requestOptions) + + // Process the full stream to get all events + for await (const part of result.fullStream) { + // Use the processAiSdkStreamPart utility to convert stream parts + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + // Yield usage metrics at the end + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage) + } + } + + /** + * Complete a prompt using the AI SDK generateText. + */ + async completePrompt(prompt: string): Promise { + const languageModel = this.getLanguageModel() + + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.config.temperature ?? 0, + }) + + return text + } +} 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 00000000000..4a82ecac4ee --- /dev/null +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -0,0 +1,492 @@ +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 user messages with URL image content", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { + type: "image", + source: { + type: "url", + url: "https://example.com/image.png", + }, + } as any, + ], + }, + ] + + 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: "https://example.com/image.png", + }, + ], + }) + }) + + it("converts tool results into separate tool role 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", + input: { path: "test.ts" }, + }, + ], + }) + // Tool results now go to role: "tool" messages per AI SDK v6 schema + 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) + // Tool results go to role: "tool" messages + expect(result[0]).toEqual({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_orphan", + toolName: "unknown_tool", + output: { type: "text", value: "Orphan result" }, + }, + ], + }) + }) + + it("separates tool results and text content into different messages", () => { + 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: "File contents here", + }, + { + type: "text", + text: "Please analyze this file", + }, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(3) + expect(result[0]).toEqual({ + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_123", + toolName: "read_file", + input: { path: "test.ts" }, + }, + ], + }) + // Tool results go first in a "tool" message + expect(result[1]).toEqual({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_123", + toolName: "read_file", + output: { type: "text", value: "File contents here" }, + }, + ], + }) + // Text content goes in a separate "user" message + expect(result[2]).toEqual({ + role: "user", + content: [{ type: "text", text: "Please analyze this file" }], + }) + }) + + 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", + input: { 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 text chunks (fullStream format)", () => { + const part = { type: "text" as const, text: "Hello from fullStream" } + const chunks = [...processAiSdkStreamPart(part as any)] + + expect(chunks).toHaveLength(1) + expect(chunks[0]).toEqual({ type: "text", text: "Hello from fullStream" }) + }) + + 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 reasoning chunks (fullStream format)", () => { + const part = { type: "reasoning" as const, text: "reasoning from fullStream" } + const chunks = [...processAiSdkStreamPart(part as any)] + + expect(chunks).toHaveLength(1) + expect(chunks[0]).toEqual({ type: "reasoning", text: "reasoning from fullStream" }) + }) + + 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 00000000000..535b932aba7 --- /dev/null +++ b/src/api/transform/ai-sdk.ts @@ -0,0 +1,282 @@ +/** + * 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 ModelMessage, type TextStreamPart } from "ai" +import type { ApiStreamChunk } from "./stream" + +/** + * Convert Anthropic messages to AI SDK ModelMessage format. + * Handles text, images, tool uses, and tool results. + * + * @param messages - Array of Anthropic message parameters + * @returns Array of AI SDK ModelMessage objects + */ +export function convertToAiSdkMessages(messages: Anthropic.Messages.MessageParam[]): ModelMessage[] { + const modelMessages: ModelMessage[] = [] + + // 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") { + modelMessages.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") { + // Handle both base64 and URL source types + const source = part.source as { type: string; media_type?: string; data?: string; url?: string } + if (source.type === "base64" && source.media_type && source.data) { + parts.push({ + type: "image", + image: `data:${source.media_type};base64,${source.data}`, + mimeType: source.media_type, + }) + } else if (source.type === "url" && source.url) { + parts.push({ + type: "image", + image: source.url, + }) + } + } 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)" }, + }) + } + } + + // AI SDK requires tool results in separate "tool" role messages + // UserContent only supports: string | Array + // ToolContent (for role: "tool") supports: Array + if (toolResults.length > 0) { + modelMessages.push({ + role: "tool", + content: toolResults, + } as ModelMessage) + } + + // Add user message with only text/image content (no tool results) + if (parts.length > 0) { + modelMessages.push({ + role: "user", + content: parts, + } as ModelMessage) + } + } else if (message.role === "assistant") { + const textParts: string[] = [] + const toolCalls: Array<{ + type: "tool-call" + toolCallId: string + toolName: string + input: 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, + input: part.input, + }) + } + } + + const content: Array< + | { type: "text"; text: string } + | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown } + > = [] + + if (textParts.length > 0) { + content.push({ type: "text", text: textParts.join("\n") }) + } + content.push(...toolCalls) + + modelMessages.push({ + role: "assistant", + content: content.length > 0 ? content : [{ type: "text", text: "" }], + } as ModelMessage) + } + } + } + + return modelMessages +} + +/** + * 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 +} + +/** + * Extended stream part type that includes additional fullStream event types + * that are emitted at runtime but not included in the AI SDK TextStreamPart type definitions. + */ +type ExtendedStreamPart = TextStreamPart | { type: "text"; text: string } | { type: "reasoning"; text: string } + +/** + * 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 (including fullStream event types) + * @yields ApiStreamChunk objects corresponding to the stream part + */ +export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator { + switch (part.type) { + case "text": + case "text-delta": + yield { type: "text", text: (part as { text: string }).text } + break + + case "reasoning": + case "reasoning-delta": + yield { type: "reasoning", text: (part as { text: string }).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 4833be362b1..02dd73b2e4a 100644 --- a/src/package.json +++ b/src/package.json @@ -765,9 +765,11 @@ "ws": "^8.18.0", "xlsx": "^0.18.5", "yaml": "^2.8.0", - "zod": "3.25.61" + "zod": "3.25.76" }, "devDependencies": { + "@ai-sdk/openai-compatible": "^1.0.0", + "@openrouter/ai-sdk-provider": "^2.0.4", "@roo-code/build": "workspace:^", "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", @@ -798,6 +800,7 @@ "@vscode/vsce": "3.3.2", "dotenv": "^16.4.7", "esbuild": "^0.25.0", + "ai": "^6.0.0", "esbuild-wasm": "^0.25.0", "execa": "^9.5.2", "glob": "^11.1.0",