diff --git a/bun.lock b/bun.lock index 1eea6f8d83fa..ce00c1142dc0 100644 --- a/bun.lock +++ b/bun.lock @@ -343,14 +343,8 @@ "name": "@opencode/core", "version": "2.0.3", "dependencies": { - "@ai-sdk/alibaba": "1.0.17", - "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.88", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/gateway": "3.0.104", - "@ai-sdk/google-vertex": "4.0.128", - "@ai-sdk/mistral": "3.0.51", - "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", @@ -369,7 +363,6 @@ "@parcel/watcher": "2.5.1", "@silvia-odwyer/photon-node": "0.3.4", "@standard-schema/spec": "catalog:", - "ai-gateway-provider": "3.1.2", "bun-pty": "0.4.8", "diff": "catalog:", "drizzle-orm": "catalog:", @@ -393,6 +386,7 @@ "zod": "catalog:", }, "devDependencies": { + "@ai-sdk/openai-compatible": "2.0.41", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@modelcontextprotocol/server": "2.0.0", @@ -1086,13 +1080,11 @@ "ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch", "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "solid-js@1.9.15": "patches/solid-js@1.9.15.patch", "vite@8.2.2": "patches/vite@8.2.2.patch", "@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", }, @@ -1195,38 +1187,10 @@ "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="], - "@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="], - - "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/openai": "3.0.67", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-PsSh7a6qW+3kQXPs1kD4wDwuZby0t1PIaB6j/1aMKmPFJ5LxcIcULLMF/bjITLt5o/8lc0t6TXIwG0zlhH7uZw=="], - - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="], - - "@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="], - - "@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="], - "@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OqcCq2PiFY1dbK/0Ck45KuvE8jfdxRuuAE9Y5w46dAk6U+9vPOeg1CDcmR+ncqmrYrhRl3nmyDttyDahyjCzAw=="], - "@ai-sdk/deepgram": ["@ai-sdk/deepgram@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8pcrQvEQCbvrrQKnD6hclBbI0hUgSrgyADykRbabxv/g9vPurfMC6n23J7dD+KZ3EcCoW+qz3IUIfySJ58gBOg=="], - - "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="], - - "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZgkausouWvO9U4ZtowNJ093bSNYOvH8zqls3uLC3+oxzWvbbTZO8SOdmFk0+gGafsXFJvq2yUX9+rEeJPwOJLw=="], - - "@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.77", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.68", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ce5IVMrW38P63MlkElmpzrFGh8rMm7no5vJ1dp+eDYV83OtDo5fAqQkcCXC7nQtQTfaTErOK9PEdNh13syUmQg=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="], - "@ai-sdk/google": ["@ai-sdk/google@3.0.73", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-o2MuIeyvZrFIeIbnbA8Thrr63irdyUBh0uWBZ2lY6yFeXuE/tcwyXF74bDKS4KvTu84uFpQfpbS/LXHGKKXz+g=="], - - "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.128", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.77", "@ai-sdk/google": "3.0.73", "@ai-sdk/openai-compatible": "2.0.47", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jK8fixb4km2yfgvb9DUFQRpV/jiDB0v9gyxHoHfPydaQvz+CpAz8DTt1quyaM+Wg9G2R8Zo68CYmHbIkUqW2AA=="], - - "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], - - "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="], - - "@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], - "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="], @@ -1237,8 +1201,6 @@ "@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="], - "@ai-sdk/xai": ["@ai-sdk/xai@3.0.123", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.69", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WNASvd1C516oh2qYIj9EvAVPdU+Abads8DQWU6p9lQtvFFeGh8QW+3LDOARZd1GCINUFfw5yadEK845SMQKLsA=="], - "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -2259,8 +2221,6 @@ "@opencode/www": ["@opencode/www@workspace:services/www"], - "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.10.0", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-FMsAEjLUt5pWuRE2LDC/LCvVrFjLlrEzUITH5+5SZtfq7KZ2wrOHjQVxzz92sju8S9ltpzW87CLW8/b0oBXVCw=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], @@ -3379,8 +3339,6 @@ "ai": ["ai@7.0.66", "", { "dependencies": { "@ai-sdk/gateway": "4.0.52", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wBUyoCYF3GVr+62nelBgR8YbpTSsMZrzFyOOjiwijylNSM2TFCW35C+Pml2vc59/WLMpyhS/LWZ55M+B9DAcSg=="], - "ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="], - "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], @@ -5947,80 +5905,14 @@ "@actions/http-client/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="], - - "@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="], - - "@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - - "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - - "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - - "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - - "@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], - - "@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="], - - "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/deepgram/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - - "@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], - - "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], - - "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - - "@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], - - "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.68", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-JiWi/Av2R+SsiGo8rv3b0yWWr3uZH+ll4ky8AcQUUGWzKcMxotQvy3oOoSR4QY34OJ4aLtctHhhzxxEMWSkfIw=="], - - "@ai-sdk/fireworks/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - - "@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], - - "@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - - "@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - - "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.77", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ML8C2M1YvPA1ulEx4TiyF0k1xvC2ikEiPBIC1PPQ0a5xELUGrO2lAaEzsTEoJ+eCeDd8PSBuFJjs+r+9yIwQXA=="], - - "@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="], - - "@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - - "@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - - "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - - "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], - - "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], - "@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="], "@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C99M0T0SpRkcCClmJxbQkpSqGmxLfh3NhTsNF3aNaUQZZ7oXN5sPWi9LGs49X5Q/r9FWxBYeZARXs15xxtGIig=="], - - "@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - - "@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], - "@astrojs/cloudflare/@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.52.1", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "5.20260811.1-alpha", "unenv": "2.0.0-rc.24", "workerd": "1.20260811.1", "wrangler": "4.123.0", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-O2IkD8E03qsUxNBdDRfDI9JPl25XIPt+76WJf8tgm206c34jYk8nn12ylBiOI/6NA8XUFhLjRTF1BGY+kVylXw=="], "@astrojs/cloudflare/wrangler": ["wrangler@4.123.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260811.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260811.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260811.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-VXo2I1oa0x9aGAKIFPRSQPqTh0RBY5Ktl44YOhNmsJQFUdJKDA2vVTU6Xj+FC2koll6orJqWZN8jbXVIk9O67Q=="], @@ -6869,14 +6761,6 @@ "@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], - "@ai-sdk/deepgram/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - - "@ai-sdk/elevenlabs/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - - "@ai-sdk/fireworks/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - - "@ai-sdk/xai/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - "@astrojs/cloudflare/@cloudflare/vite-plugin/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], "@astrojs/cloudflare/@cloudflare/vite-plugin/miniflare": ["miniflare@5.20260811.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260811.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA=="], diff --git a/packages/ai/src/providers/cloudflare-ai-gateway.ts b/packages/ai/src/providers/cloudflare-ai-gateway.ts index 7d5e7ac61d4c..d516440b7c3e 100644 --- a/packages/ai/src/providers/cloudflare-ai-gateway.ts +++ b/packages/ai/src/providers/cloudflare-ai-gateway.ts @@ -1,10 +1,9 @@ -import type { Config, Redacted } from "effect" +import { Schema, type Config, type Redacted } from "effect" import type { ProviderPackage } from "../provider-package.js" -import { OpenAIChat } from "../protocols/openai-chat.js" +import { AnthropicMessages, OpenAIChat, OpenAIResponses } from "../protocols/index.js" import { Auth } from "../route/auth.js" import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js" -import { Route, type RouteDefaultsInput } from "../route/client.js" -import { Endpoint } from "../route/endpoint.js" +import type { RouteDefaultsInput } from "../route/client.js" import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js" import type { OpenAIProviderOptionsInput } from "./openai-options.js" @@ -14,21 +13,29 @@ export const authEnvVars = ["CLOUDFLARE_API_TOKEN", "CF_AIG_TOKEN"] as const type GatewayURL = AtLeastOne<{ readonly accountId: string readonly baseURL: string -}> & { +}> + +type GatewayOptions = { readonly gatewayId?: string + readonly metadata?: unknown + readonly cacheTtl?: number + readonly cacheKey?: string + readonly skipCache?: boolean + readonly collectLog?: boolean } export type LanguageModelOptions = GatewayURL & + GatewayOptions & Omit & ProviderAuthOption<"optional"> & { - /** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */ readonly gatewayApiKey?: string | Redacted.Redacted | Config.Config readonly providerOptions?: OpenAIProviderOptionsInput } export type Settings = ProviderPackage.Settings & OpenAIProviderOptionsInput & - GatewayURL & { + GatewayURL & + GatewayOptions & { readonly apiKey?: string readonly gatewayApiKey?: string } @@ -40,73 +47,113 @@ export const baseURL = (input: GatewayURL) => { provider: id, message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied", }) - return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat` + return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1` } +export const responsesRoute = OpenAIResponses.route.with({ + id: "cloudflare-ai-gateway-responses", + provider: id, + endpoint: { baseURL: undefined }, +}) + +export const messagesRoute = AnthropicMessages.route.with({ + id: "cloudflare-ai-gateway-messages", + provider: id, + endpoint: { baseURL: undefined }, +}) + +export const route = OpenAIChat.route.with({ + id: "cloudflare-ai-gateway-chat", + provider: id, + endpoint: { baseURL: undefined }, +}) + +export const routes = [responsesRoute, messagesRoute, route] + const auth = (input: LanguageModelOptions) => { if ("auth" in input && input.auth) return input.auth - const gateway = Auth.optional(input.gatewayApiKey, "gatewayApiKey") + return Auth.optional(input.gatewayApiKey ?? ("apiKey" in input ? input.apiKey : undefined), "apiKey") .orElse(Auth.config(authEnvVars[0])) .orElse(Auth.config(authEnvVars[1])) - .pipe(Auth.bearerHeader("cf-aig-authorization")) - if (!("apiKey" in input) || input.apiKey === undefined) return gateway - if (input.gatewayApiKey === undefined) return Auth.bearer(input.apiKey) - return Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey)) + .bearer() } -export const route = Route.make({ - id: "cloudflare-ai-gateway", - provider: id, - providerMetadataKey: "cloudflare-ai-gateway", - protocol: OpenAIChat.protocol, - endpoint: Endpoint.path("/chat/completions"), - framing: OpenAIChat.framing, +const headers = (input: LanguageModelOptions) => ({ + ...(input.gatewayId === undefined ? {} : { "cf-aig-gateway-id": input.gatewayId.trim() || "default" }), + ...(input.metadata === undefined + ? {} + : { "cf-aig-metadata": Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))(input.metadata) }), + ...(input.cacheTtl === undefined ? {} : { "cf-aig-cache-ttl": String(input.cacheTtl) }), + ...(input.cacheKey === undefined ? {} : { "cf-aig-cache-key": input.cacheKey }), + ...(input.skipCache === undefined ? {} : { "cf-aig-skip-cache": String(input.skipCache) }), + ...(input.collectLog === undefined ? {} : { "cf-aig-collect-log": String(input.collectLog) }), + ...input.headers, }) -export const routes = [route] +const modelID = (input: string | ModelID) => { + const value = String(input) + if (value.startsWith("workers-ai/")) return value.slice("workers-ai/".length) + if (value.startsWith("anthropic/")) return `anthropic/${value.slice("anthropic/".length).replaceAll(".", "-")}` + return value +} export const configure = (input: LanguageModelOptions) => { - const { - accountId: _accountId, - gatewayId: _gatewayId, - apiKey: _apiKey, - gatewayApiKey: _gatewayApiKey, - baseURL: _baseURL, - auth: _auth, - ...defaults - } = input - const configured = route.with({ - ...defaults, + const defaults = { endpoint: { baseURL: baseURL(input) }, auth: auth(input), - }) + headers: headers(input), + http: input.http, + providerOptions: input.providerOptions, + } + const responses = responsesRoute.with(defaults) + const messages = messagesRoute.with(defaults) + const chat = route.with(defaults) return { id, - model: (modelID: string | ModelID) => configured.model({ id: modelID }), + model: (input: string | ModelID) => { + const wire = modelID(input) + if (String(input).startsWith("openai/")) return responses.model({ id: wire }) + if (String(input).startsWith("anthropic/")) return messages.model({ id: wire }) + return chat.model({ id: wire }) + }, configure, } } export const provider = { id, configure } -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { - const { - accountId: _, +export const model: ProviderPackage.Definition["model"] = ( + modelID, + { + accountId, apiKey, - baseURL: _url, + baseURL: configuredBaseURL, body, + cacheKey, + cacheTtl, + collectLog, gatewayApiKey, - gatewayId: _id, + gatewayId, headers, + metadata, + skipCache, ...providerOptions - } = settings + }, +) => { + const connection = configuredBaseURL === undefined ? { accountId: accountId ?? "" } : { baseURL: configuredBaseURL } return configure({ + ...connection, apiKey, + cacheKey, + cacheTtl, + collectLog, gatewayApiKey, - baseURL: baseURL(settings), + gatewayId, headers: headers === undefined ? undefined : { ...headers }, http: body === undefined ? undefined : { body: { ...body } }, + metadata, providerOptions, + skipCache, }).model(modelID) } diff --git a/packages/ai/src/providers/cloudflare-workers-ai.ts b/packages/ai/src/providers/cloudflare-workers-ai.ts index 813f9960311a..6c53c3020395 100644 --- a/packages/ai/src/providers/cloudflare-workers-ai.ts +++ b/packages/ai/src/providers/cloudflare-workers-ai.ts @@ -7,7 +7,7 @@ import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/ import type { OpenAIProviderOptionsInput } from "./openai-options.js" export const id = ProviderID.make("cloudflare-workers-ai") -export const authEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN"] as const +export const authEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN", "CLOUDFLARE_API_TOKEN"] as const type WorkersAIURL = AtLeastOne<{ readonly accountId: string diff --git a/packages/ai/test/provider/cloudflare.test.ts b/packages/ai/test/provider/cloudflare.test.ts index 62356069e200..89fcb7607fc9 100644 --- a/packages/ai/test/provider/cloudflare.test.ts +++ b/packages/ai/test/provider/cloudflare.test.ts @@ -20,33 +20,40 @@ const deltaChunk = (delta: object, finishReason: string | null = null) => ({ }) describe("Cloudflare", () => { - it.effect("prepares AI Gateway models through the OpenAI-compatible Chat protocol", () => + it.effect("selects native AI Gateway protocols by model ID", () => Effect.gen(function* () { - const model = CloudflareAIGateway.configure({ + const gateway = CloudflareAIGateway.configure({ accountId: "test-account", gatewayId: "test-gateway", apiKey: "test-token", - }).model("workers-ai/@cf/meta/llama-3.3-70b-instruct") - - expect(model).toMatchObject({ - id: "workers-ai/@cf/meta/llama-3.3-70b-instruct", - provider: "cloudflare-ai-gateway", - route: { id: "cloudflare-ai-gateway" }, }) - expect(model.route.endpoint.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat") - - const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) + const responses = yield* compileRequest( + LLM.request({ model: gateway.model("openai/gpt-5.4"), prompt: "Say hello." }), + ) + const messages = yield* compileRequest( + LLM.request({ model: gateway.model("anthropic/claude-haiku-4.5"), prompt: "Say hello." }), + ) + const chat = yield* compileRequest(LLM.request({ model: gateway.model("xai/grok-4.6"), prompt: "Say hello." })) + const workers = yield* compileRequest( + LLM.request({ model: gateway.model("workers-ai/@cf/meta/llama-3.3-70b-instruct"), prompt: "Say hello." }), + ) - expect(prepared.route).toBe("cloudflare-ai-gateway") - expect(prepared.body).toMatchObject({ - model: "workers-ai/@cf/meta/llama-3.3-70b-instruct", + expect(responses.route).toBe("cloudflare-ai-gateway-responses") + expect(responses.body).toMatchObject({ model: "openai/gpt-5.4", stream: true }) + expect(messages.route).toBe("cloudflare-ai-gateway-messages") + expect(messages.body).toMatchObject({ model: "anthropic/claude-haiku-4-5" }) + expect(chat.route).toBe("cloudflare-ai-gateway-chat") + expect(chat.body).toMatchObject({ model: "xai/grok-4.6", stream: true }) + expect(workers.route).toBe("cloudflare-ai-gateway-chat") + expect(workers.body).toMatchObject({ + model: "@cf/meta/llama-3.3-70b-instruct", messages: [{ role: "user", content: "Say hello." }], stream: true, }) }), ) - it.effect("posts to the derived gateway endpoint with bearer auth", () => + it.effect("posts to the Cloudflare REST API with gateway options", () => Effect.gen(function* () { const response = yield* LLM.generate( LLM.request({ @@ -54,7 +61,12 @@ describe("Cloudflare", () => { accountId: "test-account", gatewayId: "test-gateway", apiKey: "test-token", - }).model("openai/gpt-4o-mini"), + cacheKey: "cache-key", + cacheTtl: 300, + collectLog: false, + metadata: { invoked_by: "test" }, + skipCache: true, + }).model("xai/grok-4.6"), prompt: "Say hello.", }), ).pipe( @@ -62,12 +74,16 @@ describe("Cloudflare", () => { dynamicResponse((input) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(web.url).toBe( - "https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat/chat/completions", - ) + expect(web.url).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1/chat/completions") expect(web.headers.get("authorization")).toBe("Bearer test-token") + expect(web.headers.get("cf-aig-gateway-id")).toBe("test-gateway") + expect(web.headers.get("cf-aig-cache-key")).toBe("cache-key") + expect(web.headers.get("cf-aig-cache-ttl")).toBe("300") + expect(web.headers.get("cf-aig-collect-log")).toBe("false") + expect(web.headers.get("cf-aig-metadata")).toBe('{"invoked_by":"test"}') + expect(web.headers.get("cf-aig-skip-cache")).toBe("true") expect(decodeJson(input.text)).toMatchObject({ - model: "openai/gpt-4o-mini", + model: "xai/grok-4.6", stream: true, messages: [{ role: "user", content: "Say hello." }], }) @@ -90,7 +106,7 @@ describe("Cloudflare", () => { accountId: "test-account", gatewayId: "test-gateway", apiKey: "test-token", - }).model("anthropic/claude-sonnet-4.6") + }).model("xai/grok-4.6") const details = [ { type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 }, { type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 }, @@ -137,59 +153,19 @@ describe("Cloudflare", () => { }), ) - test("defaults AI Gateway id to default when omitted or blank", () => { - expect( - CloudflareAIGateway.configure({ - accountId: "test-account", - gatewayId: "", - gatewayApiKey: "test-token", - }).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL, - ).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat") - }) - - it.effect("supports authenticated AI Gateway plus upstream provider auth", () => - Effect.gen(function* () { - yield* LLM.generate( - LLM.request({ - model: CloudflareAIGateway.configure({ - accountId: "test-account", - gatewayApiKey: "gateway-token", - apiKey: "provider-token", - }).model("openai/gpt-4o-mini"), - prompt: "Say hello.", - }), - ).pipe( - Effect.provide( - dynamicResponse((input) => - Effect.gen(function* () { - const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(web.url).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat/chat/completions") - expect(web.headers.get("cf-aig-authorization")).toBe("Bearer gateway-token") - expect(web.headers.get("authorization")).toBe("Bearer provider-token") - return input.respond( - sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), - { headers: { "content-type": "text/event-stream" } }, - ) - }), - ), - ), - ) - }), - ) - it.effect("allows a fully configured baseURL override", () => Effect.gen(function* () { const prepared = yield* compileRequest( LLM.request({ model: CloudflareAIGateway.configure({ - baseURL: "https://gateway.proxy.test/v1/custom/compat", + baseURL: "https://gateway.proxy.test/v1", apiKey: "test-token", - }).model("openai/gpt-4o-mini"), + }).model("xai/grok-4.6"), prompt: "Say hello.", }), ) - expect(prepared.model.route.endpoint.baseURL).toBe("https://gateway.proxy.test/v1/custom/compat") + expect(prepared.model.route.endpoint.baseURL).toBe("https://gateway.proxy.test/v1") }), ) @@ -255,25 +231,31 @@ describe("Cloudflare", () => { it.effect("supports direct Workers AI token aliases through auth config", () => Effect.gen(function* () { - yield* LLM.generate( - LLM.request({ - model: CloudflareWorkersAI.configure({ - accountId: "test-account", - }).model("@cf/meta/llama-3.1-8b-instruct"), - prompt: "Say hello.", - }), - ).pipe( - withEnv({ CLOUDFLARE_WORKERS_AI_TOKEN: "test-token" }), - Effect.provide( - dynamicResponse((input) => - Effect.gen(function* () { - const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(web.headers.get("authorization")).toBe("Bearer test-token") - return input.respond( - sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), - { headers: { "content-type": "text/event-stream" } }, - ) - }), + yield* Effect.forEach(["CLOUDFLARE_WORKERS_AI_TOKEN", "CLOUDFLARE_API_TOKEN"], (name) => + LLM.generate( + LLM.request({ + model: CloudflareWorkersAI.configure({ + accountId: "test-account", + }).model("@cf/meta/llama-3.1-8b-instruct"), + prompt: "Say hello.", + }), + ).pipe( + withEnv({ + CLOUDFLARE_API_KEY: undefined, + CLOUDFLARE_WORKERS_AI_TOKEN: name === "CLOUDFLARE_WORKERS_AI_TOKEN" ? "test-token" : undefined, + CLOUDFLARE_API_TOKEN: name === "CLOUDFLARE_API_TOKEN" ? "test-token" : undefined, + }), + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("authorization")).toBe("Bearer test-token") + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), ), ), ) diff --git a/packages/core/package.json b/packages/core/package.json index 03f1c6a9672c..218979b4d87c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -81,6 +81,7 @@ } }, "devDependencies": { + "@ai-sdk/openai-compatible": "2.0.41", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@modelcontextprotocol/server": "2.0.0", @@ -100,14 +101,8 @@ "drizzle-kit": "catalog:" }, "dependencies": { - "@ai-sdk/alibaba": "1.0.17", - "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.88", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/gateway": "3.0.104", - "@ai-sdk/google-vertex": "4.0.128", - "@ai-sdk/mistral": "3.0.51", - "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", @@ -126,7 +121,6 @@ "@parcel/watcher": "2.5.1", "@silvia-odwyer/photon-node": "0.3.4", "@standard-schema/spec": "catalog:", - "ai-gateway-provider": "3.1.2", "bun-pty": "0.4.8", "diff": "catalog:", "drizzle-orm": "catalog:", diff --git a/packages/core/src/aisdk-native.ts b/packages/core/src/aisdk-native.ts index 217d8fce280e..c5b2f69a8874 100644 --- a/packages/core/src/aisdk-native.ts +++ b/packages/core/src/aisdk-native.ts @@ -3,122 +3,160 @@ export * as AISDKNative from "./aisdk-native.js" import { Effect, Option, Schema, Struct } from "effect" import { Provider } from "./provider.js" -export interface Mapping { - readonly package: string - readonly settings: Provider.Settings - readonly headers?: Readonly> - readonly body?: Readonly> +type Overlays = { + settings?: Provider.Settings + headers?: Record + body?: Record } -export interface MapInput { - readonly packageName: string | undefined - readonly settings: Provider.Settings - readonly modelID: string - readonly providerID: string +type Target = Overlays & { + package?: string + variants?: (Overlays & { id: ID })[] } -// A wrongly typed legacy value is dropped rather than failing the whole decode. -const lenient = (schema: S) => - Schema.optional(Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined))))) +type Context = { readonly providerID: string; readonly canonical?: string; readonly modelID?: string } -const Credentials = Schema.Struct({ - accessKeyId: Schema.String, - secretAccessKey: Schema.String, - sessionToken: lenient(Schema.String), - region: lenient(Schema.String), +export function rewrite( + target: Target, + input: Context & { readonly specifier: string | undefined }, +) { + if (input.specifier === undefined) return + const plain = resolve(input.specifier, input) + if (!plain) return + const settings = decode(target.settings ?? {}) + const replacement = resolve(input.specifier, { ...input, settings }) ?? plain + const translated = options(replacement, input.modelID, settings) + if (target.package !== undefined || replacement !== input.specifier) target.package = replacement + target.settings = + target.settings === undefined + ? undefined + : { + ...translated.settings, + ...(!NATIVE.has(input.specifier) && replacement === "@opencode/ai/providers/openai-compatible" + ? { provider: input.canonical ?? input.providerID } + : {}), + } + target.headers = Provider.mergeHeaders(translated.headers, target.headers) + target.body = Provider.mergeOverlay(translated.body, target.body) + target.variants = target.variants?.map((variant) => { + const overlay = options(replacement, input.modelID, decode(variant.settings ?? {})) + const headers = Provider.mergeHeaders(overlay.headers, variant.headers) + const body = Provider.mergeOverlay(overlay.body, variant.body) + return { + id: variant.id, + ...(variant.settings === undefined ? {} : { settings: overlay.settings }), + ...(headers === undefined ? {} : { headers }), + ...(body === undefined ? {} : { body }), + } + }) +} + +const PACKAGES: Readonly> = { + "@ai-sdk/amazon-bedrock": "@opencode/ai/providers/amazon-bedrock", + "@ai-sdk/alibaba": "@opencode/ai/providers/alibaba/chat", + "@ai-sdk/anthropic": "@opencode/ai/providers/anthropic", + "@ai-sdk/azure": "@opencode/ai/providers/azure/responses", + "@ai-sdk/cerebras": "@opencode/ai/providers/cerebras", + "@ai-sdk/deepinfra": "@opencode/ai/providers/deepinfra", + "@ai-sdk/google": "@opencode/ai/providers/google", + "@ai-sdk/google-vertex": "@opencode/ai/providers/google-vertex", + "@ai-sdk/google-vertex/anthropic": "@opencode/ai/providers/google-vertex/messages", + "@ai-sdk/groq": "@opencode/ai/providers/groq", + "@ai-sdk/mistral": "@opencode/ai/providers/mistral", + "@ai-sdk/openai": "@opencode/ai/providers/openai", + "@ai-sdk/openai-compatible": "@opencode/ai/providers/openai-compatible", + "@ai-sdk/togetherai": "@opencode/ai/providers/togetherai", + "@ai-sdk/xai": "@opencode/ai/providers/xai", + "@openrouter/ai-sdk-provider": "@opencode/ai/providers/openrouter", + "ai-gateway-provider": "@opencode/ai/providers/cloudflare-ai-gateway", +} + +const protocols = (name: string) => ({ + "@ai-sdk/openai-compatible": `@opencode/ai/providers/${name}/chat`, + "@ai-sdk/anthropic": `@opencode/ai/providers/${name}/messages`, + "@ai-sdk/openai": `@opencode/ai/providers/${name}/responses`, }) -/** AI SDK settings whose spelling differs from the native package. Everything else passes through. */ -const Legacy = Schema.StructWithRest( - Schema.Struct({ - apiKey: lenient(Schema.String), - baseURL: lenient(Schema.String), - headers: lenient(Schema.Record(Schema.String, Schema.String)), - extraBody: lenient(Schema.Record(Schema.String, Schema.Unknown)), - useCompletionUrls: lenient(Schema.Boolean), - // Bedrock - auth: lenient(Schema.Literals(["bearer", "sigv4"])), - bearerToken: lenient(Schema.String), - endpoint: lenient(Schema.String), - region: lenient(Schema.String), - credentials: lenient(Credentials), - accessKeyId: lenient(Schema.String), - secretAccessKey: lenient(Schema.String), - sessionToken: lenient(Schema.String), - anthropicBeta: lenient(Schema.Array(Schema.String)), - serviceTier: lenient(Schema.String), - reasoningConfig: lenient( - Schema.Struct({ - type: lenient(Schema.String), - display: lenient(Schema.String), - maxReasoningEffort: lenient(Schema.String), - budgetTokens: lenient(Schema.Number), - }), - ), - additionalModelRequestFields: lenient( - Schema.StructWithRest( - Schema.Struct({ - anthropic_beta: lenient(Schema.Array(Schema.String)), - output_config: lenient(Schema.Record(Schema.String, Schema.Unknown)), - reasoning: lenient(Schema.Record(Schema.String, Schema.Unknown)), - }), - [Schema.Record(Schema.String, Schema.Unknown)], - ), - ), - // OpenRouter - appName: lenient(Schema.String), - appUrl: lenient(Schema.String), - api_keys: lenient(Schema.Record(Schema.String, Schema.String)), - }), - [Schema.Record(Schema.String, Schema.Unknown)], -) -type Legacy = typeof Legacy.Type -const decode = Schema.decodeUnknownSync(Legacy) +const HOSTS: Readonly>>> = { + alibaba: protocols("alibaba"), + "alibaba-cn": protocols("alibaba"), + "alibaba-coding-plan": protocols("alibaba"), + "alibaba-coding-plan-cn": protocols("alibaba"), + "alibaba-token-plan": protocols("alibaba"), + "alibaba-token-plan-cn": protocols("alibaba"), + baseten: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/baseten" }, + "cloudflare-ai-gateway": { + "@ai-sdk/anthropic": "@opencode/ai/providers/cloudflare-ai-gateway", + "@ai-sdk/openai": "@opencode/ai/providers/cloudflare-ai-gateway", + "@ai-sdk/openai-compatible": "@opencode/ai/providers/cloudflare-ai-gateway", + "ai-gateway-provider": "@opencode/ai/providers/cloudflare-ai-gateway", + }, + "cloudflare-workers-ai": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/cloudflare-workers-ai" }, + deepseek: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/deepseek" }, + "fireworks-ai": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/fireworks" }, + "google-vertex": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/google-vertex/chat" }, + "kimi-for-coding": protocols("moonshot"), + meta: protocols("meta"), + minimax: protocols("minimax"), + "minimax-cn": protocols("minimax"), + "minimax-coding-plan": protocols("minimax"), + "minimax-cn-coding-plan": protocols("minimax"), + moonshotai: protocols("moonshot"), + "moonshotai-cn": protocols("moonshot"), + zai: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/zai/chat" }, + "zai-coding-plan": protocols("zai-coding-plan"), + zhipuai: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/zai/chat" }, + "zhipuai-coding-plan": protocols("zai-coding-plan"), +} -/** Maps a legacy AI SDK package onto the native package that replaces it. */ -export function map(input: MapInput): Mapping | undefined { - const settings = decode(input.settings) - const native = mapPackage(input.packageName, input.modelID, settings) - if (!native) return - const converse = native === "@opencode/ai/providers/amazon-bedrock" - const mapped = { - ...Struct.omit(settings, ["headers", "extraBody", "useCompletionUrls", ...OPENROUTER_KEYS]), - ...(native === "@opencode/ai/providers/openai-compatible" ? { provider: input.providerID } : {}), - } +const NATIVE = new Set([ + ...Object.values(PACKAGES), + ...Object.values(HOSTS).flatMap((host) => Object.values(host)), + "@opencode/ai/providers/azure/chat", + "@opencode/ai/providers/amazon-bedrock/mantle", + "@opencode/ai/providers/amazon-bedrock/mantle/chat", + "@opencode/ai/providers/amazon-bedrock/mantle/responses", +]) + +export function native(npm: string, context: Context & { readonly settings?: Provider.Settings }): string | undefined { + const host = HOSTS[context.providerID]?.[npm] + if (host) return host + if (npm === "@ai-sdk/amazon-bedrock/mantle") return mantle(context.modelID) + if (npm === "@ai-sdk/azure" && context.settings?.useCompletionUrls === true) + return "@opencode/ai/providers/azure/chat" + return PACKAGES[npm] +} + +const mantle = (modelID: string | undefined) => { + if (modelID === undefined) return "@opencode/ai/providers/amazon-bedrock/mantle" + return `@opencode/ai/providers/amazon-bedrock/mantle/${modelID.includes("gpt-oss") ? "chat" : "responses"}` +} + +function resolve(specifier: string, context: Context & { readonly settings?: Provider.Settings }): string | undefined { + const npm = Provider.packageName(specifier) + if (Provider.isAISDK(specifier) || npm in PACKAGES || npm in (HOSTS[context.providerID] ?? {})) + return native(npm, context) + if (npm === "@opencode/ai/providers/amazon-bedrock/mantle") return mantle(context.modelID) + if (npm === "@opencode/ai/providers/azure/responses" && context.settings?.useCompletionUrls === true) + return "@opencode/ai/providers/azure/chat" + return NATIVE.has(npm) ? npm : undefined +} + +type Overlay = { + readonly settings: Provider.Settings + readonly headers?: Readonly> + readonly body?: Readonly> +} + +function options(replacement: string, modelID: string | undefined, settings: Legacy): Overlay { + const converse = replacement === "@opencode/ai/providers/amazon-bedrock" && modelID !== undefined + const kept = Struct.omit(settings, ["headers", "extraBody", "useCompletionUrls", ...OPENROUTER_KEYS]) return { - package: native, - settings: native.startsWith("@opencode/ai/providers/amazon-bedrock") ? bedrockSettings(mapped, converse) : mapped, + settings: replacement.startsWith("@opencode/ai/providers/amazon-bedrock") ? bedrockSettings(kept, converse) : kept, ...(settings.headers === undefined ? {} : { headers: settings.headers }), ...(settings.extraBody === undefined ? {} : { body: settings.extraBody }), - ...(converse ? bedrockRequest(input.modelID, settings) : {}), - ...(native === "@opencode/ai/providers/openrouter" ? openRouterRequest(settings) : {}), - } -} - -function mapPackage(packageName: string | undefined, modelID: string, settings: Legacy) { - switch (packageName) { - case "@ai-sdk/anthropic": - case "@ai-sdk/cerebras": - case "@ai-sdk/deepinfra": - case "@ai-sdk/google": - case "@ai-sdk/google-vertex": - case "@ai-sdk/groq": - case "@ai-sdk/mistral": - case "@ai-sdk/openai": - case "@ai-sdk/togetherai": - case "@ai-sdk/xai": - case "@ai-sdk/amazon-bedrock": - return `@opencode/ai/providers/${packageName.slice("@ai-sdk/".length)}` - case "@ai-sdk/amazon-bedrock/mantle": - return `@opencode/ai/providers/amazon-bedrock/mantle/${modelID.includes("gpt-oss") ? "chat" : "responses"}` - case "@ai-sdk/azure": - return `@opencode/ai/providers/azure/${settings.useCompletionUrls === true ? "chat" : "responses"}` - case "@ai-sdk/google-vertex/anthropic": - return "@opencode/ai/providers/google-vertex/messages" - case "@ai-sdk/openai-compatible": - return settings.baseURL === undefined ? undefined : "@opencode/ai/providers/openai-compatible" - case "@openrouter/ai-sdk-provider": - return "@opencode/ai/providers/openrouter" + ...(converse ? bedrockRequest(modelID, settings) : {}), + ...(replacement === "@opencode/ai/providers/openrouter" ? openRouterRequest(settings) : {}), } } @@ -145,11 +183,11 @@ function bedrockSettings(settings: Legacy, converse: boolean) { ? {} : { baseURL: region === undefined ? baseURL : baseURL.replaceAll("${AWS_REGION}", region) }), ...(settings.apiKey === undefined && settings.bearerToken !== undefined ? { apiKey: settings.bearerToken } : {}), - ...(region === undefined || credentials.accessKeyId === undefined || credentials.secretAccessKey === undefined + ...(credentials.accessKeyId === undefined || credentials.secretAccessKey === undefined ? {} : { credentials: { - region, + ...(region === undefined ? {} : { region }), accessKeyId: credentials.accessKeyId, secretAccessKey: credentials.secretAccessKey, ...(credentials.sessionToken === undefined ? {} : { sessionToken: credentials.sessionToken }), @@ -158,13 +196,13 @@ function bedrockSettings(settings: Legacy, converse: boolean) { } } -function bedrockRequest(modelID: string, settings: Legacy): Pick { +function bedrockRequest(modelID: string | undefined, settings: Legacy): Pick { const additional = settings.additionalModelRequestFields ?? {} const reasoning = settings.reasoningConfig - const anthropic = modelID.includes("anthropic") - const openai = modelID.includes("openai.") + const anthropic = modelID?.includes("anthropic") ?? false + const openai = modelID?.includes("openai.") ?? false // gpt-oss (Harmony) takes the flat chat-completions `reasoning_effort`; GPT-5.6+ take Responses-style `reasoning.effort`. - const harmony = modelID.includes("openai.gpt-oss") + const harmony = modelID?.includes("openai.gpt-oss") ?? false const effort = reasoning?.maxReasoningEffort const type = reasoning?.type const budget = reasoning?.budgetTokens @@ -204,7 +242,7 @@ function bedrockRequest(modelID: string, settings: Legacy): Pick { +function openRouterRequest(settings: Legacy): Pick { const headers = Provider.mergeHeaders( { @@ -218,3 +256,57 @@ function openRouterRequest(settings: Legacy): Pick { ) ?? {} return Object.keys(headers).length === 0 ? {} : { headers } } + +const lenient = (schema: S) => + Schema.optional(Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined))))) + +const Credentials = Schema.Struct({ + accessKeyId: Schema.String, + secretAccessKey: Schema.String, + sessionToken: lenient(Schema.String), + region: lenient(Schema.String), +}) + +const Legacy = Schema.StructWithRest( + Schema.Struct({ + apiKey: lenient(Schema.String), + baseURL: lenient(Schema.String), + headers: lenient(Schema.Record(Schema.String, Schema.String)), + extraBody: lenient(Schema.Record(Schema.String, Schema.Unknown)), + useCompletionUrls: lenient(Schema.Boolean), + auth: lenient(Schema.Literals(["bearer", "sigv4"])), + bearerToken: lenient(Schema.String), + endpoint: lenient(Schema.String), + region: lenient(Schema.String), + credentials: lenient(Credentials), + accessKeyId: lenient(Schema.String), + secretAccessKey: lenient(Schema.String), + sessionToken: lenient(Schema.String), + anthropicBeta: lenient(Schema.Array(Schema.String)), + serviceTier: lenient(Schema.String), + reasoningConfig: lenient( + Schema.Struct({ + type: lenient(Schema.String), + display: lenient(Schema.String), + maxReasoningEffort: lenient(Schema.String), + budgetTokens: lenient(Schema.Number), + }), + ), + additionalModelRequestFields: lenient( + Schema.StructWithRest( + Schema.Struct({ + anthropic_beta: lenient(Schema.Array(Schema.String)), + output_config: lenient(Schema.Record(Schema.String, Schema.Unknown)), + reasoning: lenient(Schema.Record(Schema.String, Schema.Unknown)), + }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ), + appName: lenient(Schema.String), + appUrl: lenient(Schema.String), + api_keys: lenient(Schema.Record(Schema.String, Schema.String)), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +type Legacy = typeof Legacy.Type +const decode = Schema.decodeUnknownSync(Legacy) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index c54675e902ea..9b348c3ac14d 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -6,8 +6,9 @@ import { ConfigProvider } from "@opencode/schema/config/provider" import { Money } from "@opencode/schema/money" import { Effect } from "effect" import { Config } from "../../config.js" -import { Provider } from "../../provider.js" import { Model } from "../../model.js" +import { Provider } from "../../provider.js" +import { Variant } from "../../variant.js" import { ConfigEntryObserver } from "./entry-observer.js" export const Plugin = define({ @@ -156,6 +157,15 @@ export const Plugin = define({ if (config.disabled !== undefined) model.enabled = !config.disabled if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } }) + if (config.variants === undefined && !source?.base) + models.update(providerID, id, (model) => { + model.variants = [ + ...Variant.resolve({ + ...model, + package: model.package ?? models.provider.get(providerID)?.provider.package, + }), + ] + }) } } }) diff --git a/packages/core/src/github-copilot/models.ts b/packages/core/src/github-copilot/models.ts index fa6efaaaf2a4..8af9fbf496f7 100644 --- a/packages/core/src/github-copilot/models.ts +++ b/packages/core/src/github-copilot/models.ts @@ -159,7 +159,7 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo providerID: Provider.ID.githubCopilot, family: previous?.family ?? Model.Family.make(remote.capabilities.family), name: previous?.name ?? remote.name, - package: Provider.aisdk(messages ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot"), + package: messages ? "@opencode/ai/providers/anthropic" : Provider.aisdk("@ai-sdk/github-copilot"), settings: Provider.mergeOverlay(previous?.settings, { baseURL: messages ? `${baseURL}/v1` : baseURL, ...(endpoint ? { endpoint } : {}), diff --git a/packages/core/src/modal/models.ts b/packages/core/src/modal/models.ts index 81cfca15511c..2832117492a6 100644 --- a/packages/core/src/modal/models.ts +++ b/packages/core/src/modal/models.ts @@ -107,8 +107,8 @@ function build(id: Model.ID, remote: RemoteModel, baseURL: string, previous?: Mo remote.interleaved === undefined ? previous?.compatibility : (Model.compatibility(remote.interleaved) ?? previous?.compatibility), - package: Provider.aisdk("@ai-sdk/openai-compatible"), - settings: Provider.mergeOverlay(previous?.settings, { baseURL }), + package: "@opencode/ai/providers/openai-compatible", + settings: Provider.mergeOverlay(previous?.settings, { baseURL, provider: providerID }), headers: previous?.headers, body: previous?.body, capabilities: { diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 205352b574cd..6d4b94e6ead0 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -5,7 +5,6 @@ import { LanguageModel, ProviderConfigurationError } from "@opencode/ai" import { Auth } from "@opencode/ai/route" import { Context, Effect, Layer, Schema, Struct } from "effect" import { AISDK } from "./aisdk.js" -import { AISDKNative } from "./aisdk-native.js" import { Credential } from "./credential.js" import { Integration } from "./integration.js" import { Capabilities, ID, Info, Model, Ref, VariantID } from "./model.js" @@ -193,19 +192,9 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct dependencies?: Dependencies, ) { const resolved = prepareRuntimeModel(model, credential) - const packageName = Provider.packageName(resolved.package) const configuration = credential?.type === "key" ? credential.configuration : undefined const configured = { ...resolved.settings, ...credential?.metadata, ...configuration } - const mapping = Provider.isAISDK(resolved.package) - ? AISDKNative.map({ - packageName, - settings: configured, - modelID: resolved.modelID ?? resolved.id, - providerID: resolved.canonical ?? resolved.providerID, - }) - : undefined - const native = mapping?.package ?? packageName - if (Provider.isAISDK(resolved.package) && !mapping) { + if (Provider.isAISDK(resolved.package)) { const loadAISDK = dependencies?.loadAISDK if (!loadAISDK) return yield* unsupported(resolved) const settings = yield* prepareProviderSettings( @@ -220,10 +209,9 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct Effect.mapError((error) => initialization(resolved, "init", error.cause)), ) } - if (!native) return yield* unsupported(resolved) - - const specifier = native - const mapped = yield* prepareProviderSettings(resolved, Provider.nativeSettings(mapping?.settings ?? configured)) + const specifier = Provider.packageName(resolved.package) + if (!specifier) return yield* unsupported(resolved) + const mapped = yield* prepareProviderSettings(resolved, Provider.nativeSettings(configured)) const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe( Effect.mapError((error) => initialization(resolved, "load", error.cause)), ) @@ -231,8 +219,8 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct ...(credential ? Struct.omit(mapped, ["accessToken", "apiKey", "authToken"]) : mapped), ...(resolved.canonical === undefined ? {} : { provider: resolved.canonical }), ...nativeCredentialSettings(specifier, credential), - headers: Provider.mergeHeaders(mapping?.headers, resolved.headers), - body: Provider.mergeOverlay(mapping?.body, resolved.body), + headers: resolved.headers, + body: resolved.body, } return yield* Effect.try({ try: () => { diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 8c21f3bdab9a..67b8d5054276 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -7,6 +7,7 @@ import { Bus } from "./bus.js" import { State } from "./state.js" import { Location } from "./location.js" import { freeze } from "immer" +import { AISDKNative } from "./aisdk-native.js" export const ID = Model.ID export type ID = typeof ID.Type @@ -111,6 +112,13 @@ const layer = Layer.effect( update(model) model.id = modelID model.providerID = providerID + const provider = input?.records.get(providerID)?.provider + AISDKNative.rewrite(model, { + specifier: model.package ?? provider?.package, + providerID, + canonical: model.canonical ?? provider?.canonical, + modelID: model.modelID ?? modelID, + }) models.set(modelID, model) }, remove: (providerID, modelID) => { diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 9818ff38379c..82de7b33daf9 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -9,7 +9,9 @@ import { Bus } from "./bus.js" import { makeGlobalNode } from "@opencode/util/effect/app-node" import { httpClient } from "@opencode/util/effect/app-node-platform" import { Model } from "./model.js" +import { AISDKNative } from "./aisdk-native.js" import { Provider } from "./provider.js" +import { Variant } from "./variant.js" import { KV } from "./kv.js" import snapshotText from "./models-dev/snapshot.txt" with { type: "text" } @@ -25,11 +27,6 @@ type Cost = { readonly context_over_200k?: Omit } -type ReasoningOption = - | { readonly type: "effort"; readonly values: readonly (string | null)[] } - | { readonly type: "toggle" } - | { readonly type: "budget_tokens"; readonly min?: number; readonly max?: number } - type Modality = "text" | "audio" | "image" | "video" | "pdf" type SourceModel = { @@ -39,7 +36,10 @@ type SourceModel = { readonly release_date: string readonly attachment: boolean readonly reasoning: boolean - readonly reasoning_options?: readonly ReasoningOption[] + readonly reasoning_options?: readonly ( + | { readonly type: "effort"; readonly values: readonly (string | null)[] } + | Exclude + )[] readonly temperature?: boolean readonly tool_call: boolean readonly interleaved?: boolean | string | { readonly field: string } @@ -79,27 +79,36 @@ export type Snapshot = { readonly environment: readonly string[] } +function nativePackage(provider: SourceProvider, model?: SourceModel) { + const npm = model?.provider?.npm ?? provider.npm + return AISDKNative.native(npm, { providerID: provider.id, modelID: model?.id }) ?? Provider.aisdk(npm) +} + function normalize(input: Record): readonly Snapshot[] { const providers: Snapshot[] = [] for (const item of Object.values(input)) { const providerID = Provider.ID.make(item.id) + const packageName = nativePackage(item) const info = { id: providerID, name: item.name, activation: "auto", - package: Provider.aisdk(item.npm), - ...(item.api ? { settings: { baseURL: item.api } } : {}), + package: packageName, + ...(item.api && packageName !== "@opencode/ai/providers/cloudflare-workers-ai" + ? { settings: { baseURL: item.api } } + : {}), } satisfies Provider.Info const models: Model.Info[] = [] for (const model of Object.values(item.models)) { const baseCost = cost(model.cost) - const variants = reasoningVariants(item, model) const id = Model.ID.make(model.id) - models.push(modelInfo(providerID, id, model, { cost: baseCost, variants })) + const base = modelInfo(item, id, model, { cost: baseCost }) + const variants = Variant.resolve({ ...base, package: nativePackage(item, model) }, supports(model)) + models.push({ ...base, variants }) for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { const modeID = Model.ID.make(`${model.id}-${mode}`) models.push( - modelInfo(providerID, modeID, model, { + modelInfo(item, modeID, model, { name: modeName(model, mode), cost: mergeCost(baseCost, options.cost), request: options.provider, @@ -184,303 +193,20 @@ function mergeCost(base: Model.Info["cost"], override: SourceModel["cost"] | und ] } -const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] -const OUTPUT_TOKEN_MAX = 32_000 - -function reasoningVariants(provider: SourceProvider, model: SourceModel): NonNullable { - const npm = model.provider?.npm ?? provider.npm - const options = model.reasoning_options - if (!options?.length) return [] - const toggle = options.some((option) => option.type === "toggle") - const effort = options.find((option) => option.type === "effort") - if (effort?.type === "effort") { - const off = toggle ? toggleVariants(npm, model.id).filter((variant) => variant.id === "none") : [] - const variants = [ - ...off, - ...effort.values.flatMap((value) => { - const raw: unknown = value - const id = typeof raw === "string" && raw !== "null" ? raw : undefined - if (id === undefined) return [] - if (id === "none" && off.length > 0) return [] - const settings = settingsForEffort(npm, model.id, id) - return settings ? [{ id: Model.VariantID.make(id), settings }] : [] - }), - ] - return [...new Map(variants.map((variant) => [variant.id, variant])).values()] - } - const budget = options.find((option) => option.type === "budget_tokens") - if (budget?.type === "budget_tokens") - return [ - ...(toggle ? toggleVariants(npm, model.id).filter((variant) => variant.id === "none") : []), - ...budgetVariants(npm, model, budget), - ] - if (toggle) return toggleVariants(npm, model.id) - return [] +function modeName(model: SourceModel, mode: string) { + return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` } -function settingsForEffort(npm: string, modelID: string, effort: string): Provider.Settings | undefined { - if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { effort } } - if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { - if (anthropicManualThinking(modelID)) return { effort } - return { - thinking: { type: "adaptive", display: "summarized" }, - effort, - } - } - if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") - return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } - if (npm === "@ai-sdk/amazon-bedrock") { - if (modelID.includes("anthropic")) - return { - reasoningConfig: { - ...(anthropicManualThinking(modelID) ? {} : { type: "adaptive", display: "summarized" }), - maxReasoningEffort: effort, - }, - } - return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } } - } - if (npm === "@ai-sdk/gateway") { - const upstream = gatewayPackage(modelID) - if (upstream) return settingsForEffort(upstream, modelID, effort) - return { reasoningEffort: effort } - } - if (npm === "@ai-sdk/github-copilot") { - if (modelID.includes("gemini")) return - if (modelID.includes("claude")) return { reasoningEffort: effort } - return { reasoningEffort: effort, reasoningSummary: "auto", include: OPENAI_INCLUDE_ENCRYPTED_REASONING } - } - if (npm === "@ai-sdk/openai" || npm === "@ai-sdk/amazon-bedrock/mantle" || npm === "@ai-sdk/azure") - return { reasoningEffort: effort, reasoningSummary: "auto", include: OPENAI_INCLUDE_ENCRYPTED_REASONING } - if (npm === "@jerome-benoit/sap-ai-provider-v2") { - if (modelID.includes("anthropic")) - return { - modelParams: { - additionalModelRequestFields: { - ...(anthropicManualThinking(modelID) ? {} : { thinking: { type: "adaptive", display: "summarized" } }), - output_config: { effort }, - }, - }, - } - if (modelID.includes("gemini")) - return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } } - if (modelID.includes("amazon--nova")) - return { modelParams: { additionalModelRequestFields: { output_config: { effort } } } } - return { modelParams: { reasoning_effort: effort } } - } - if ( - [ - "@ai-sdk/openai-compatible", - "@ai-sdk/xai", - "@ai-sdk/mistral", - "@ai-sdk/groq", - "@ai-sdk/cerebras", - "@ai-sdk/deepinfra", - "@ai-sdk/togetherai", - "venice-ai-sdk-provider", - "ai-gateway-provider", - ].includes(npm) +function supports(model: SourceModel): readonly Variant.Support[] { + return (model.reasoning_options ?? []).map((option) => + option.type === "effort" + ? { type: "effort", values: option.values.filter((value): value is string => value !== null && value !== "null") } + : option, ) - return { reasoningEffort: effort } -} - -function budgetVariants( - npm: string, - model: SourceModel, - option: Extract[number], { type: "budget_tokens" }>, -): NonNullable { - const maximum = Math.min(option.max ?? OUTPUT_TOKEN_MAX - 1, model.limit.output - 1, OUTPUT_TOKEN_MAX - 1) - if (maximum <= 0) return [] - const high = Math.min(Math.max(option.min ?? 0, Math.floor((maximum + 1) / 2)), maximum) - return [ - { id: "high", budget: high }, - { id: "max", budget: maximum }, - ].flatMap((item) => { - const settings = settingsForBudget(npm, model.id, item.budget) - return settings ? [{ id: Model.VariantID.make(item.id), settings }] : [] - }) -} - -function toggleVariants(npm: string, modelID: string): NonNullable { - if (npm === "@ai-sdk/gateway") { - const upstream = gatewayPackage(modelID) - if (upstream) return toggleVariants(upstream, modelID) - return [ - { - id: Model.VariantID.make("none"), - settings: { reasoning: { enabled: false } }, - }, - { - id: Model.VariantID.make("thinking"), - settings: { reasoning: { enabled: true } }, - }, - ] - } - if (npm === "@openrouter/ai-sdk-provider") - return [ - { id: Model.VariantID.make("none"), settings: { reasoning: { enabled: false } } }, - { id: Model.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } }, - ] - if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") - return [ - { id: Model.VariantID.make("none"), settings: { thinking: { type: "disabled" } } }, - { - id: Model.VariantID.make("thinking"), - settings: { - thinking: { type: "adaptive", display: "summarized" }, - }, - }, - ] - if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") - return [ - { - id: Model.VariantID.make("none"), - settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } }, - }, - { - id: Model.VariantID.make("thinking"), - settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } }, - }, - ] - if (npm === "@ai-sdk/amazon-bedrock") { - const anthropic = modelID.includes("anthropic") - return [ - { - id: Model.VariantID.make("none"), - settings: { - additionalModelRequestFields: anthropic - ? { thinking: { type: "disabled" } } - : { reasoningConfig: { type: "disabled" } }, - }, - }, - { - id: Model.VariantID.make("thinking"), - settings: { - additionalModelRequestFields: anthropic - ? { thinking: { type: "adaptive", display: "summarized" } } - : { reasoningConfig: { type: "enabled" } }, - }, - }, - ] - } - if (npm === "@ai-sdk/alibaba") - return [ - { id: Model.VariantID.make("none"), settings: { enableThinking: false } }, - { id: Model.VariantID.make("thinking"), settings: { enableThinking: true } }, - ] - if (npm === "@ai-sdk/cohere") - return [ - { id: Model.VariantID.make("none"), settings: { thinking: { type: "disabled" } } }, - { id: Model.VariantID.make("thinking"), settings: { thinking: { type: "enabled" } } }, - ] - if (npm === "@jerome-benoit/sap-ai-provider-v2") { - if (modelID.includes("gemini")) - return [ - { - id: Model.VariantID.make("none"), - settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } }, - }, - { - id: Model.VariantID.make("thinking"), - settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } } }, - }, - ] - if (modelID.includes("cohere")) - return [ - { - id: Model.VariantID.make("none"), - settings: { modelParams: { thinking: { type: "disabled" } } }, - }, - { - id: Model.VariantID.make("thinking"), - settings: { modelParams: { thinking: { type: "enabled" } } }, - }, - ] - if (modelID.includes("amazon--nova")) - return [ - { - id: Model.VariantID.make("none"), - settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } } }, - }, - { - id: Model.VariantID.make("thinking"), - settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "enabled" } } } }, - }, - ] - if (modelID.includes("anthropic")) - return [ - { - id: Model.VariantID.make("none"), - settings: { - modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } }, - }, - }, - { - id: Model.VariantID.make("thinking"), - settings: { - modelParams: { - additionalModelRequestFields: { - thinking: { type: "adaptive", display: "summarized" }, - }, - }, - }, - }, - ] - } - return [] -} - -function settingsForBudget(npm: string, modelID: string, budget: number): Provider.Settings | undefined { - if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { max_tokens: budget } } - if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") - return { thinking: { type: "enabled", budgetTokens: budget } } - if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") - return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } - if (npm === "@ai-sdk/amazon-bedrock") return { reasoningConfig: { type: "enabled", budgetTokens: budget } } - if (npm === "@ai-sdk/gateway") { - const upstream = gatewayPackage(modelID) - return upstream ? settingsForBudget(upstream, modelID, budget) : { reasoning: { max_tokens: budget } } - } - if (npm === "@ai-sdk/cohere") return { thinking: { type: "enabled", tokenBudget: budget } } - if (npm === "@ai-sdk/alibaba") return { enableThinking: true, thinkingBudget: budget } - if (npm === "@jerome-benoit/sap-ai-provider-v2") { - if (modelID.includes("anthropic")) - return { - modelParams: { - additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: budget } }, - }, - } - if (modelID.includes("gemini")) - return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } } - if (modelID.includes("cohere")) return { modelParams: { thinking: { type: "enabled", token_budget: budget } } } - } -} - -function gatewayPackage(modelID: string) { - const separator = modelID.indexOf("/") - if (separator <= 0) return - const prefix = modelID.slice(0, separator) - if (prefix === "anthropic") return "@ai-sdk/anthropic" - if (prefix === "google") return "@ai-sdk/google" - if (prefix === "amazon") return "@ai-sdk/amazon-bedrock" - if (prefix === "alibaba") return "@ai-sdk/alibaba" -} - -function anthropicManualThinking(modelID: string) { - const familyFirst = /(?:claude-)?(?:opus|sonnet|haiku)-(\d+)(?:[.-](\d+))?/i.exec(modelID) - const versionFirst = /claude-(\d+)(?:[.-](\d+))?-(?:opus|sonnet|haiku)/i.exec(modelID) - const major = Number(familyFirst?.[1] ?? versionFirst?.[1]) - const rawMinor = Number(familyFirst?.[2] ?? versionFirst?.[2] ?? 0) - if (!Number.isFinite(major)) return false - const minor = rawMinor > 9 ? 0 : rawMinor - return major < 4 || (major === 4 && minor < 6) -} - -function modeName(model: SourceModel, mode: string) { - return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` } function modelInfo( - providerID: Provider.ID, + provider: SourceProvider, id: Model.ID, model: SourceModel, input: { @@ -490,6 +216,13 @@ function modelInfo( readonly variants?: NonNullable } = {}, ): Model.Info { + const providerID = Provider.ID.make(provider.id) + const pkg = model.provider?.npm ? nativePackage(provider, model) : undefined + // Per model, so it never merges into a model that overrides to a different package. + const settings = { + ...(model.provider?.api ? { baseURL: model.provider.api } : {}), + ...(nativePackage(provider, model) === "@opencode/ai/providers/openai-compatible" ? { provider: providerID } : {}), + } return { id, modelID: Model.ID.make(model.id), @@ -497,8 +230,8 @@ function modelInfo( name: input.name ?? model.name, compatibility: Model.compatibility(model.interleaved), family: model.family ? Model.Family.make(model.family) : undefined, - package: model.provider?.npm ? Provider.aisdk(model.provider.npm) : undefined, - settings: model.provider?.api ? { baseURL: model.provider.api } : undefined, + package: pkg, + settings: Object.keys(settings).length === 0 ? undefined : settings, capabilities: { tools: model.tool_call, input: [...(model.modalities?.input ?? [])], diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index bb1bdb46880f..294e68a4d33e 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -91,7 +91,6 @@ import { WebSearchPlugins } from "./websearch/index.js" import { SkillPlugin } from "./skill.js" import { VcsHgPlugin } from "./vcs/hg.js" import { OptimizePlugin } from "./optimize.js" -import { VariantPlugin } from "./variant.js" import { VcsGitPlugin } from "./vcs/git.js" import { WarmingPlugin } from "./warming.js" import { WellKnownPlugin } from "../wellknown/plugin.js" @@ -245,7 +244,6 @@ const post = [ ConfigProviderPlugin.Plugin, ConfigWebSearchPlugin.Plugin, ConfigWorktreePlugin.Plugin, - VariantPlugin.Plugin, ConfigPolicyPlugin.Plugin, ] as const satisfies readonly InternalPlugin[] diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index c17017188988..bb4268f06b15 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -77,6 +77,8 @@ function environmentNames(provider: ModelsDev.Snapshot) { // Vertex. Those configure Google auth rather than carrying a key, so only the // Express Mode key may become a credential; GoogleVertexPlugin handles activation. if (provider.info.id === Provider.ID.googleVertex) return ["GOOGLE_VERTEX_API_KEY"] + if (provider.info.id === "cloudflare-workers-ai") + return ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN", "CLOUDFLARE_API_TOKEN"] return [...provider.environment] } diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 731bdabddb1a..d91e920fbe7c 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -1,4 +1,3 @@ -import { AlibabaPlugin } from "./provider/alibaba.js" import { AmazonBedrockPlugin } from "./provider/amazon-bedrock.js" import { AzurePlugin } from "./provider/azure.js" import { CerebrasPlugin } from "./provider/cerebras.js" @@ -19,7 +18,6 @@ import { NvidiaPlugin } from "./provider/nvidia.js" import { OllamaPlugin } from "./provider/ollama.js" import { OpenAIPlugin } from "./provider/openai.js" import { SnowflakeCortexPlugin } from "./provider/snowflake-cortex.js" -import { OpenAICompatiblePlugin } from "./provider/openai-compatible.js" import { OpencodePlugin } from "./provider/opencode.js" import { OpenRouterPlugin } from "./provider/openrouter.js" import { PerplexityPlugin } from "./provider/perplexity.js" @@ -33,7 +31,6 @@ import { ZenmuxPlugin } from "./provider/zenmux.js" import type { PluginInternal } from "./internal.js" export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ - AlibabaPlugin, AmazonBedrockPlugin, AzurePlugin, CerebrasPlugin, @@ -53,7 +50,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ OllamaPlugin, OpencodePlugin, SnowflakeCortexPlugin, - OpenAICompatiblePlugin, OpenAIPlugin, OpenRouterPlugin, PerplexityPlugin, diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts deleted file mode 100644 index fed3f14fcfec..000000000000 --- a/packages/core/src/plugin/provider/alibaba.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createProviderPlugin } from "./factory.js" - -export const AlibabaPlugin = createProviderPlugin({ - id: "opencode.provider.alibaba", - package: "@ai-sdk/alibaba", - load: async (options) => { - const { createAlibaba } = await import("@ai-sdk/alibaba") - return createAlibaba(options) - }, -}) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index d1f07e57982a..a8cb75e5c200 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -13,10 +13,8 @@ const CHAIN_ENV = [ "AWS_CONTAINER_CREDENTIALS_FULL_URI", ] -const isBedrock = (item: { readonly package: string }) => { - const name = Provider.packageName(item.package) - return name.startsWith("@ai-sdk/amazon-bedrock") || name.startsWith("@opencode/ai/providers/amazon-bedrock") -} +const isBedrock = (item: { readonly package: string }) => + item.package.startsWith("@opencode/ai/providers/amazon-bedrock") export const AmazonBedrockPlugin = define({ id: "opencode.provider.amazon.bedrock", diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 4a855cdfeaea..0e4f8343734c 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -134,7 +134,10 @@ export const AzurePlugin = define({ yield* load() yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { - if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure") + if ( + item.provider.id !== Provider.ID.azure && + !item.provider.package.startsWith("@opencode/ai/providers/azure/") + ) continue const resourceName = resolveResourceName(item.provider.settings, loaded.resource) if (resourceName) @@ -151,7 +154,10 @@ export const AzurePlugin = define({ }) yield* ctx.model.transform((models) => { for (const item of models.provider.list()) { - if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure") + if ( + item.provider.id !== Provider.ID.azure && + !item.provider.package.startsWith("@opencode/ai/providers/azure/") + ) continue const resourceName = resolveResourceName(item.provider.settings, loaded.resource) for (const model of models.list(item.provider.id)) { @@ -216,9 +222,9 @@ function expandResourceName(baseURL: string, resourceName: string) { } function responsesWebSocketCapable(provider: Provider.Info, model: Model.Info) { - if (Provider.packageName(model.package ?? provider.package) !== "@ai-sdk/azure") return false + if ((model.package ?? provider.package) !== "@opencode/ai/providers/azure/responses") return false const settings = Provider.mergeOverlay(provider.settings, model.settings) - if (settings?.useCompletionUrls === true || settings?.useDeploymentBasedUrls === true) return false + if (settings?.useDeploymentBasedUrls === true) return false if (settings?.apiVersion !== undefined && settings.apiVersion !== "v1") return false if (typeof settings?.baseURL !== "string") return true return /^https:\/\/[^/]+\.openai\.azure\.com(?:\/|$)/i.test(settings.baseURL) diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index a73a266686cf..492448f193fd 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -1,14 +1,12 @@ import { Effect } from "effect" import { define } from "@opencode/plugin/effect/plugin" -import { Provider } from "../../provider.js" export const CerebrasPlugin = define({ id: "opencode.provider.cerebras", effect: Effect.fn(function* (ctx) { yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { - const name = Provider.packageName(item.provider.package) - if (name !== "@ai-sdk/cerebras" && name !== "@opencode/ai/providers/cerebras") continue + if (item.provider.package !== "@opencode/ai/providers/cerebras") continue evt.update(item.provider.id, (provider) => { provider.headers = { ...provider.headers, "X-Cerebras-3rd-Party-Integration": "opencode" } }) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index 45d1ff7fa4c4..f62b1f894cd0 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -1,6 +1,4 @@ -import os from "os" -import { App } from "../../app.js" -import { Effect, Option, Schema } from "effect" +import { Effect } from "effect" import { define } from "@opencode/plugin/effect/plugin" import { Form } from "@opencode/schema/form" import { Provider } from "../../provider.js" @@ -49,75 +47,9 @@ export const CloudflareAIGatewayPlugin = define({ }, }) }) - yield* ctx.aisdk.hook( - "sdk", - Effect.fn(function* (evt) { - if (evt.package !== "ai-gateway-provider") return - if (evt.options.baseURL) return - - const config = gatewayConfig(evt.options) - if (!config) return - const metadata = gatewayMetadata(evt.options) - const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")) - const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")) - const gateway = createAiGateway({ - accountId: config.accountId, - gateway: config.gatewayId, - apiKey: config.apiKey, - options: gatewayOptions(evt.options, metadata, ctx.app), - } as any) - const unified = createUnified({ apiKey: config.apiKey }) - evt.sdk = { - languageModel(modelID: string) { - return gateway(unified(modelID)) - }, - } - }), - ) }), }) -type GatewayConfig = { - accountId: string - gatewayId: string - apiKey: string -} - -const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) - -function gatewayConfig(options: Record): GatewayConfig | undefined { - const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId") - // Credential projection copies key metadata into options. The form stores the - // gateway as gatewayId, while older config examples may use gateway. - const gatewayId = - process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway") - const apiKey = process.env.CLOUDFLARE_API_TOKEN ?? process.env.CF_AIG_TOKEN ?? stringOption(options, "apiKey") - if (!accountId || !gatewayId || !apiKey) return undefined - - return { accountId, gatewayId, apiKey } -} - -function gatewayMetadata(options: Record) { - // Preserve the legacy cf-aig-metadata header escape hatch for gateway logging - // metadata, but prefer the typed metadata option when present. - if (options.metadata !== undefined) return options.metadata - const raw = (options.headers as Record | undefined)?.["cf-aig-metadata"] - return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined -} - -function gatewayOptions(options: Record, metadata: unknown, app: App.Info) { - return { - metadata, - cacheTtl: options.cacheTtl, - cacheKey: options.cacheKey, - skipCache: options.skipCache, - collectLog: options.collectLog, - headers: { - "User-Agent": `${App.useragent(app)} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, - }, - } -} - function stringOption(options: Record, key: string) { return typeof options[key] === "string" ? options[key] : undefined } diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index e23bbfeed338..1edfb10cd764 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -1,5 +1,3 @@ -import os from "os" -import { App } from "../../app.js" import { Effect } from "effect" import { define } from "@opencode/plugin/effect/plugin" import { Form } from "@opencode/schema/form" @@ -39,39 +37,15 @@ export const CloudflareWorkersAIPlugin = define({ const item = evt.get(providerID) if (!item) return evt.update(item.provider.id, (provider) => { - if (!Provider.isAISDK(provider.package)) return if (typeof provider.settings?.baseURL === "string") return const accountId = resolveAccountId(provider.settings ?? {}) - if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) } + if (!accountId) return + provider.settings = + provider.package === "@opencode/ai/providers/cloudflare-workers-ai" + ? { ...provider.settings, accountId } + : { ...provider.settings, baseURL: workersEndpoint(accountId) } }) }) - yield* ctx.aisdk.hook( - "sdk", - Effect.fn(function* (evt) { - if (evt.model.providerID !== providerID) return - if (evt.package !== "@ai-sdk/openai-compatible") return - - const accountId = resolveAccountId(evt.options) - if (!hasWorkersEndpoint(evt.model) && !accountId) return - const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) - evt.sdk = mod.createOpenAICompatible( - sdkOptions( - { - ...evt.options, - baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined), - }, - ctx.app, - ) as any, - ) - }), - ) - yield* ctx.aisdk.hook( - "language", - Effect.fn(function* (evt) { - if (evt.model.providerID !== providerID) return - evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id) - }), - ) }), }) @@ -83,31 +57,6 @@ function workersEndpoint(accountId: string) { return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1` } -function hasWorkersEndpoint(model: { - readonly package?: string - readonly settings?: Readonly> -}) { - return Provider.isAISDK(model.package) && typeof model.settings?.baseURL === "string" -} - -function sdkOptions(options: Record, app: App.Info) { - return { - ...options, - baseURL: expandAccountId(options.baseURL), - apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey, - headers: { - "User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, - ...options.headers, - }, - name: providerID, - } -} - -function expandAccountId(baseURL: unknown) { - if (typeof baseURL !== "string") return baseURL - return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", process.env.CLOUDFLARE_ACCOUNT_ID ?? "${CLOUDFLARE_ACCOUNT_ID}") -} - function stringOption(options: Record, key: string) { return typeof options[key] === "string" ? options[key] : undefined } diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 2b02cbe08da8..b31fd9afef5d 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -38,33 +38,16 @@ function replaceVertexVars(value: string, project: string | undefined, location: .replaceAll("${GOOGLE_VERTEX_ENDPOINT}", vertexEndpoint(location)) } -function authFetch(fetchWithRuntimeOptions?: unknown) { - // Native Vertex SDKs handle ADC internally. OpenAI-compatible Vertex endpoints - // do not, so inject a Google access token into their fetch path. - return async (input: Parameters[0], init?: RequestInit) => { - const { GoogleAuth } = await import("google-auth-library") - const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }) - const client = await auth.getClient() - const token = await client.getAccessToken() - const headers = new Headers(init?.headers) - headers.set("Authorization", `Bearer ${token.token}`) - return typeof fetchWithRuntimeOptions === "function" - ? fetchWithRuntimeOptions(input, { ...init, headers }) - : fetch(input, { ...init, headers }) - } -} - export const GoogleVertexPlugin = define({ id: "opencode.provider.google.vertex", effect: Effect.fn(function* (ctx) { yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { - if (!Provider.isAISDK(item.provider.package)) continue if ( - Provider.packageName(item.provider.package) !== "@ai-sdk/google-vertex" && + !item.provider.package.startsWith("@opencode/ai/providers/google-vertex") && !( item.provider.id === Provider.ID.googleVertex && - Provider.packageName(item.provider.package)?.includes("@ai-sdk/openai-compatible") + item.provider.package === "@opencode/ai/providers/openai-compatible" ) ) continue @@ -85,15 +68,29 @@ export const GoogleVertexPlugin = define({ }) } }) - yield* ctx.aisdk.hook( - "sdk", - Effect.fn(function* (evt) { - if (evt.model.providerID === Provider.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { - evt.options.fetch = authFetch(evt.options.fetch) - return + yield* ctx.model.transform((models) => { + for (const item of models.provider.list()) { + if ( + !item.provider.package.startsWith("@opencode/ai/providers/google-vertex") && + !( + item.provider.id === Provider.ID.googleVertex && + item.provider.package === "@opencode/ai/providers/openai-compatible" + ) + ) + continue + const project = resolveProject(item.provider.settings ?? {}) + const location = String(resolveLocation(item.provider.settings ?? {})) + for (const model of models.list(item.provider.id)) { + if (typeof model.settings?.baseURL !== "string") continue + models.update(item.provider.id, model.id, (draft) => { + draft.settings = { + ...draft.settings, + baseURL: replaceVertexVars(String(draft.settings?.baseURL), project, location), + } + }) } - }), - ) + } + }) yield* ctx.aisdk.hook( "language", Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index 7941b75ba160..f588b182a7a9 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -1,14 +1,12 @@ import { Effect } from "effect" import { define } from "@opencode/plugin/effect/plugin" -import { Provider } from "../../provider.js" export const KiloPlugin = define({ id: "opencode.provider.kilo", effect: Effect.fn(function* (ctx) { yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue + if (item.provider.package !== "@opencode/ai/providers/openai-compatible") continue if (item.provider.settings?.baseURL !== "https://api.kilo.ai/api/gateway") continue evt.update(item.provider.id, (provider) => { provider.headers = { ...provider.headers, "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" } diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index a019cb3a3dc4..652f7fe515dd 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -11,8 +11,7 @@ export const LLMGatewayPlugin = define({ yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { if (item.provider.activation === "disabled") continue - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue + if (item.provider.package !== "@opencode/ai/providers/openai-compatible") continue if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue if (!configured.has(Integration.ID.make(item.provider.id))) continue evt.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 94a8c7f082e2..883981a494fd 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -1,14 +1,12 @@ import { Effect } from "effect" import { define } from "@opencode/plugin/effect/plugin" -import { Provider } from "../../provider.js" export const NvidiaPlugin = define({ id: "opencode.provider.nvidia", effect: Effect.fn(function* (ctx) { yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue + if (item.provider.package !== "@opencode/ai/providers/openai-compatible") continue if (item.provider.settings?.baseURL !== "https://integrate.api.nvidia.com/v1") continue evt.update(item.provider.id, (provider) => { provider.headers = { diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts deleted file mode 100644 index 0e256c1b034f..000000000000 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Effect } from "effect" -import { define } from "@opencode/plugin/effect/plugin" - -export const OpenAICompatiblePlugin = define({ - id: "opencode.provider.openai.compatible", - effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", - Effect.fn(function* (evt) { - if (evt.sdk) return - if (!evt.package.includes("@ai-sdk/openai-compatible")) return - if (evt.options.includeUsage !== false) evt.options.includeUsage = true - const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) - evt.sdk = mod.createOpenAICompatible(evt.options as any) - }), - ) - }), -}) diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index c62999f31625..91886973400b 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -1,6 +1,5 @@ import { Effect } from "effect" import { Model } from "../../model.js" -import { Provider } from "../../provider.js" import { define } from "@opencode/plugin/effect/plugin" export const OpenRouterPlugin = define({ @@ -8,8 +7,7 @@ export const OpenRouterPlugin = define({ effect: Effect.fn(function* (ctx) { yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@openrouter/ai-sdk-provider") continue + if (item.provider.package !== "@opencode/ai/providers/openrouter") continue evt.update(item.provider.id, (provider) => { provider.headers = { ...provider.headers, "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" } }) @@ -17,8 +15,7 @@ export const OpenRouterPlugin = define({ }) yield* ctx.model.transform((models) => { for (const item of models.provider.list()) { - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@openrouter/ai-sdk-provider") continue + if (item.provider.package !== "@opencode/ai/providers/openrouter") continue for (const modelID of [Model.ID.make("gpt-5-chat-latest"), Model.ID.make("openai/gpt-5-chat")]) { if (!models.get(item.provider.id, modelID)) continue models.update(item.provider.id, modelID, (model) => { diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 140e5345707a..81a7103c95a7 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -1,14 +1,12 @@ import { Effect } from "effect" import { define } from "@opencode/plugin/effect/plugin" -import { Provider } from "../../provider.js" export const ZenmuxPlugin = define({ id: "opencode.provider.zenmux", effect: Effect.fn(function* (ctx) { yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue + if (item.provider.package !== "@opencode/ai/providers/openai-compatible") continue if (item.provider.settings?.baseURL !== "https://zenmux.ai/api/v1") continue evt.update(item.provider.id, (provider) => { provider.headers = { diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts deleted file mode 100644 index 794a87ed72d4..000000000000 --- a/packages/core/src/plugin/variant.ts +++ /dev/null @@ -1,41 +0,0 @@ -export * as VariantPlugin from "./variant.js" - -import { Effect } from "effect" -import { define } from "@opencode/plugin/effect/plugin" -import { Model } from "@opencode/schema/model" -import { Provider } from "../provider.js" - -export const Plugin = define({ - id: "opencode.variant", - effect: Effect.fn(function* (ctx) { - yield* ctx.model.transform((models) => { - for (const model of models.list()) { - const generated = generate(model, models.provider.get(model.providerID)?.provider) - if (generated.length === 0) continue - models.update(model.providerID, model.id, (draft) => { - const variants = draft.variants ?? [] - const explicit = new Map(variants.map((variant) => [variant.id, variant])) - const generatedIDs = new Set(generated.map((variant) => variant.id)) - draft.variants = [ - ...generated.map((variant) => explicit.get(variant.id) ?? variant), - ...variants.filter((variant) => !generatedIDs.has(variant.id)), - ] - }) - } - }) - }), -}) - -export function generate( - model: { readonly id: string; readonly modelID?: string; readonly package?: string }, - provider?: { readonly package: string }, -): NonNullable { - const packageName = model.package ?? provider?.package - if (!Provider.isAISDK(packageName) || Provider.packageName(packageName) !== "@ai-sdk/openai-compatible") return [] - const ids = `${model.id} ${model.modelID ?? ""}`.toLowerCase() - if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return [] - return ["high", "max"].map((id) => ({ - id: Model.VariantID.make(id), - settings: { reasoningEffort: id }, - })) -} diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 2ca054b87c01..f1bfe668aa66 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -16,6 +16,7 @@ import { IntegrationConnection } from "./integration/connection.js" import { Credential } from "@opencode/schema/credential" import { Location } from "./location.js" import { freeze } from "immer" +import { AISDKNative } from "./aisdk-native.js" export const ID = Provider.ID export type ID = typeof ID.Type @@ -55,6 +56,9 @@ const builtins = new Map Promise>([ "@opencode/ai/providers/amazon-bedrock/mantle/responses", () => import("@opencode/ai/providers/amazon-bedrock/mantle/responses"), ], + ["@opencode/ai/providers/alibaba/chat", () => import("@opencode/ai/providers/alibaba/chat")], + ["@opencode/ai/providers/alibaba/messages", () => import("@opencode/ai/providers/alibaba/messages")], + ["@opencode/ai/providers/alibaba/responses", () => import("@opencode/ai/providers/alibaba/responses")], ["@opencode/ai/providers/anthropic", () => import("@opencode/ai/providers/anthropic")], ["@opencode/ai/providers/azure", () => import("@opencode/ai/providers/azure")], ["@opencode/ai/providers/azure/chat", () => import("@opencode/ai/providers/azure/chat")], @@ -73,7 +77,16 @@ const builtins = new Map Promise>([ ["@opencode/ai/providers/google-vertex/responses", () => import("@opencode/ai/providers/google-vertex/responses")], ["@opencode/ai/providers/google-vertex/messages", () => import("@opencode/ai/providers/google-vertex/messages")], ["@opencode/ai/providers/groq", () => import("@opencode/ai/providers/groq")], + ["@opencode/ai/providers/meta/chat", () => import("@opencode/ai/providers/meta/chat")], + ["@opencode/ai/providers/meta/messages", () => import("@opencode/ai/providers/meta/messages")], + ["@opencode/ai/providers/meta/responses", () => import("@opencode/ai/providers/meta/responses")], + ["@opencode/ai/providers/minimax/chat", () => import("@opencode/ai/providers/minimax/chat")], + ["@opencode/ai/providers/minimax/messages", () => import("@opencode/ai/providers/minimax/messages")], + ["@opencode/ai/providers/minimax/responses", () => import("@opencode/ai/providers/minimax/responses")], ["@opencode/ai/providers/mistral", () => import("@opencode/ai/providers/mistral")], + ["@opencode/ai/providers/moonshot/chat", () => import("@opencode/ai/providers/moonshot/chat")], + ["@opencode/ai/providers/moonshot/messages", () => import("@opencode/ai/providers/moonshot/messages")], + ["@opencode/ai/providers/moonshot/responses", () => import("@opencode/ai/providers/moonshot/responses")], ["@opencode/ai/providers/openai", () => import("@opencode/ai/providers/openai")], ["@opencode/ai/providers/openai/chat", () => import("@opencode/ai/providers/openai/chat")], ["@opencode/ai/providers/openai/responses", () => import("@opencode/ai/providers/openai/responses")], @@ -81,6 +94,13 @@ const builtins = new Map Promise>([ ["@opencode/ai/providers/openrouter", () => import("@opencode/ai/providers/openrouter")], ["@opencode/ai/providers/togetherai", () => import("@opencode/ai/providers/togetherai")], ["@opencode/ai/providers/xai", () => import("@opencode/ai/providers/xai")], + ["@opencode/ai/providers/zai/chat", () => import("@opencode/ai/providers/zai/chat")], + ["@opencode/ai/providers/zai-coding-plan/chat", () => import("@opencode/ai/providers/zai-coding-plan/chat")], + ["@opencode/ai/providers/zai-coding-plan/messages", () => import("@opencode/ai/providers/zai-coding-plan/messages")], + [ + "@opencode/ai/providers/zai-coding-plan/responses", + () => import("@opencode/ai/providers/zai-coding-plan/responses"), + ], ]) export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: string, npm?: Npm.Interface) { @@ -269,6 +289,11 @@ const layer = Layer.effect( const record = entry(id) update(record.provider) record.provider.id = id + AISDKNative.rewrite(record.provider, { + specifier: record.provider.package, + providerID: id, + canonical: record.provider.canonical, + }) }, remove: (id) => { records.delete(id) @@ -278,19 +303,26 @@ const layer = Layer.effect( entry(id).models = index(values) }, update: (providerID, modelID, update) => { + const record = entry(providerID) const target = writable(providerID) const current = target.get(modelID) const model = // An earlier add/set can publish and freeze an owned model within this fold. - ( - current && models.has(current) && !Object.isFrozen(current) - ? current - : current - ? structuredClone(current) - : Model.Info.default(providerID, modelID) - ) as DeepMutable + ( + current && models.has(current) && !Object.isFrozen(current) + ? current + : current + ? structuredClone(current) + : Model.Info.default(providerID, modelID) + ) as DeepMutable update(model) model.id = modelID model.providerID = providerID + AISDKNative.rewrite(model, { + specifier: model.package ?? record.provider.package, + providerID, + canonical: model.canonical ?? record.provider.canonical, + modelID: model.modelID ?? modelID, + }) models.add(model) target.set(modelID, model) }, diff --git a/packages/core/src/variant.ts b/packages/core/src/variant.ts new file mode 100644 index 000000000000..969524a12b84 --- /dev/null +++ b/packages/core/src/variant.ts @@ -0,0 +1,554 @@ +export * as Variant from "./variant.js" + +import { Model } from "./model.js" +import { Provider } from "./provider.js" + +export type Support = + | { readonly type: "effort"; readonly values?: readonly string[] } + | { readonly type: "toggle" } + | { readonly type: "budget_tokens"; readonly min?: number; readonly max?: number } + +type Variants = Model.Info["variants"] +type Overlay = Omit + +type Protocol = (model: Model.Info, support: Support) => Variants + +export function resolve(model: Model.Info, supports: readonly Support[] = [{ type: "effort" }]): Variants { + const protocol = model.package === undefined ? undefined : PROTOCOLS[model.package] + if (!protocol) return [] + const toggle = supports.some((support) => support.type === "toggle") ? protocol(model, { type: "toggle" }) : [] + const effort = supports.find((support) => support.type === "effort") + const budget = supports.find((support) => support.type === "budget_tokens") + const main = effort ? protocol(model, effort) : budget ? protocol(model, budget) : toggle + const variants = [...toggle.filter((variant) => variant.id === "none"), ...main] + return variants.filter((variant, index) => variants.findIndex((other) => other.id === variant.id) === index) +} + +const EFFORTS = ["low", "medium", "high"] +const ENCRYPTED_REASONING = ["reasoning.encrypted_content"] +const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" } + +const variant = (id: string, overlay: Overlay): Variants[number] => ({ id: Model.VariantID.make(id), ...overlay }) + +const efforts = (values: readonly string[], spell: (effort: string) => Overlay): Variants => + values.map((effort) => variant(effort, spell(effort))) + +const toggle = (off: Overlay, on: Overlay): Variants => [variant("none", off), variant("thinking", on)] + +function budgets( + model: Model.Info, + support: Extract, + spell: (tokens: number) => Overlay, +): Variants { + const maximum = Math.min(support.max ?? model.limit.output - 1, model.limit.output - 1) + if (maximum <= 0) return [] + const high = Math.min(Math.max(support.min ?? 0, Math.floor((maximum + 1) / 2)), maximum) + return [variant("high", spell(high)), variant("max", spell(maximum))] +} + +const modelID = (model: Model.Info) => model.modelID ?? model.id + +function claudeInfo(model: Model.Info) { + const id = modelID(model) + const familyFirst = /(?:claude-)?(opus|sonnet|haiku|fable|mythos)-(\d+)(?:[.-](\d+))?/i.exec(id) + const versionFirst = /claude-(\d+)(?:[.-](\d+))?-(opus|sonnet|haiku|fable|mythos)/i.exec(id) + const family = (familyFirst?.[1] ?? versionFirst?.[3])?.toLowerCase() + const major = Number(familyFirst?.[2] ?? versionFirst?.[1]) + const minor = Number(familyFirst?.[3] ?? versionFirst?.[2] ?? 0) + return { + family, + major, + minor, + manual: (major === 3 && minor === 7) || (major === 4 && minor < 6), + always: family === "fable" || family === "mythos" || id.toLowerCase().includes("mythos-preview"), + } +} + +function manualThinking(model: Model.Info): Overlay | undefined { + const tokens = Math.min(16_000, model.limit.output - 1) + if (tokens < 1024) return + return { settings: { thinking: { type: "enabled", budgetTokens: tokens } } } +} + +const openaiChat: Protocol = (_, support) => { + if (support.type !== "effort") return [] + return efforts(support.values ?? EFFORTS, (effort) => ({ settings: { reasoningEffort: effort } })) +} + +const openaiResponses: Protocol = (_, support) => { + if (support.type !== "effort") return [] + return efforts(support.values ?? ["none", "minimal", ...EFFORTS, "xhigh"], responsesEffort) +} + +const responsesEffort = (effort: string): Overlay => ({ + settings: { reasoningEffort: effort, reasoningSummary: "auto", include: ENCRYPTED_REASONING }, +}) + +const cloudflareAIGateway: Protocol = (model, support) => { + const id = modelID(model) + if (id.startsWith("openai/")) return openaiResponses(model, support) + if (id.startsWith("anthropic/")) return anthropicMessages(model, support) + return openaiChat(model, support) +} + +const deepseekChat: Protocol = (_, support) => { + switch (support.type) { + case "effort": + return efforts(support.values ?? ["low", "high", "max"], (effort) => { + if (effort === "none") return { body: { thinking: { type: "disabled" } } } + return { settings: { reasoningEffort: effort }, body: { thinking: { type: "enabled" } } } + }) + case "toggle": + return toggle({ body: { thinking: { type: "disabled" } } }, { body: { thinking: { type: "enabled" } } }) + case "budget_tokens": + return [] + } +} + +const nvidiaTemplate = (chat_template_kwargs: Record): Overlay => ({ + body: { chat_template_kwargs }, +}) + +const nvidiaToggle = (key: string): Variants => + toggle(nvidiaTemplate({ [key]: false }), nvidiaTemplate({ [key]: true })) + +const nvidiaChat: Protocol = (model, support) => { + const id = modelID(model).toLowerCase() + if (id.includes("deepseek-v4")) { + if (support.type === "toggle") return nvidiaToggle("thinking") + if (support.type !== "effort") return [] + return efforts(support.values ?? ["none", "high", "max"], (effort) => + effort === "none" + ? nvidiaTemplate({ thinking: false }) + : nvidiaTemplate({ thinking: true, reasoning_effort: effort }), + ) + } + if (id.includes("kimi-k2.6") || id.includes("kimi-k2-6")) return nvidiaToggle("thinking") + if (id.includes("kimi-k3")) { + if (support.type === "toggle") return nvidiaToggle("thinking") + if (support.type !== "effort") return [] + return efforts(support.values ?? ["low", "high", "max"], (effort) => ({ + settings: { reasoningEffort: effort }, + })) + } + if (id.includes("minimax-m3")) + return toggle(nvidiaTemplate({ thinking_mode: "disabled" }), nvidiaTemplate({ thinking_mode: "enabled" })) + if (id.includes("glm")) + return toggle( + nvidiaTemplate({ enable_thinking: false }), + nvidiaTemplate({ enable_thinking: true, clear_thinking: false }), + ) + const toggleOnly = id.includes("qwen") || id.includes("gemma") || id.includes("nemotron") + switch (support.type) { + case "effort": + return toggleOnly && support.values === undefined ? nvidiaToggle("enable_thinking") : openaiChat(model, support) + case "toggle": + return nvidiaToggle("enable_thinking") + case "budget_tokens": + return budgets(model, support, (tokens) => ({ + body: { chat_template_kwargs: { enable_thinking: true }, reasoning_budget: tokens }, + })) + } +} + +const basetenTemplate = (enable_thinking: boolean): Overlay => ({ + body: { chat_template_args: { enable_thinking } }, +}) + +const basetenChat: Protocol = (model, support) => { + const id = modelID(model).toLowerCase() + switch (support.type) { + case "effort": { + const glm = id.includes("glm-5.2") || id.includes("glm-5-2") || id.includes("glm-5p2") + return efforts(support.values ?? EFFORTS, (effort) => ({ + settings: { reasoningEffort: effort }, + ...(glm ? basetenTemplate(effort !== "none") : {}), + ...(id.includes("deepseek-v4-pro-0813") && effort !== "none" + ? { body: { thinking: { type: "enabled" } } } + : {}), + })) + } + case "toggle": + return toggle(basetenTemplate(false), basetenTemplate(true)) + case "budget_tokens": + return [] + } +} + +const deepinfraChat: Protocol = (model, support) => { + if (/kimi[-.]?k2[.-]7-code/i.test(modelID(model))) return [] + switch (support.type) { + case "effort": + return efforts(support.values ?? EFFORTS, (effort) => ({ settings: { reasoningEffort: effort } })) + case "toggle": + return toggle({ body: { reasoning: { enabled: false } } }, { body: { reasoning: { enabled: true } } }) + case "budget_tokens": + return [] + } +} + +const openaiCompatible: Protocol = (model, support) => { + if (model.providerID === "nvidia") return nvidiaChat(model, support) + return openaiChat(model, support) +} + +const moonshotChat: Protocol = (model, support) => { + const id = modelID(model).toLowerCase() + const toggleable = id.includes("k2.5") || id.includes("k2-5") || id.includes("k2.6") || id.includes("k2-6") + const fixed = id.includes("k2-thinking") || id.includes("k2.7-code") || id.includes("k2-7-code") + if (fixed) return [] + if (toggleable && (support.type === "toggle" || (support.type === "effort" && support.values === undefined))) + return toggle({ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: { type: "enabled" } } }) + if (toggleable || support.type !== "effort") return [] + return efforts(support.values ?? ["low", "high", "max"], (effort) => ({ + settings: { reasoningEffort: effort }, + })) +} + +const alibabaChat: Protocol = (model, support) => { + const id = modelID(model).toLowerCase() + const fixed = + id.includes("-thinking") || id.includes("qwq") || id.includes("deepseek-r1") || id.includes("minimax-m2") + switch (support.type) { + case "effort": { + if (fixed && support.values === undefined) return [] + const hosted = id.includes("glm") || id.includes("deepseek") + const values = support.values ?? (hosted ? ["high", "max"] : ["low", "medium", "xhigh"]) + return efforts(values, (effort) => { + if (effort === "none") return { settings: { enableThinking: false } } + return { settings: { enableThinking: true, reasoningEffort: effort } } + }) + } + case "toggle": + return toggle({ settings: { enableThinking: false } }, { settings: { enableThinking: true } }) + case "budget_tokens": + return budgets(model, support, (tokens) => ({ + settings: { enableThinking: true, thinkingBudget: tokens }, + })) + } +} + +const zaiChat: Protocol = (model, support) => { + const id = modelID(model).toLowerCase() + const version = /glm-?(\d+)(?:(?:[.-]|p)(\d+))?/.exec(id) + const major = Number(version?.[1]) + const minor = Number(version?.[2] ?? 0) + const coding = model.package?.includes("zai-coding-plan") ?? false + const latest = coding || !Number.isFinite(major) || major > 5 || (major === 5 && minor >= 3) + const effort = major === 5 && minor === 2 + if (support.type === "toggle" || (!latest && !effort && support.type === "effort" && support.values === undefined)) { + if (latest) return [] + return toggle( + { settings: { thinking: { type: "disabled" } } }, + { settings: { thinking: { type: "enabled", clear_thinking: false } } }, + ) + } + if (support.type !== "effort") return [] + return efforts(support.values ?? (latest ? ["low", "high", "max"] : ["high", "max"]), (value) => { + if (value === "none" || value === "minimal") return { settings: { thinking: { type: "disabled" } } } + return { + settings: { thinking: { type: "enabled", clear_thinking: false }, reasoningEffort: value }, + } + }) +} + +const anthropicMessages: Protocol = (model, support) => { + const info = claudeInfo(model) + const opus45 = info.family === "opus" && info.major === 4 && info.minor === 5 + switch (support.type) { + case "effort": { + if (info.manual && !opus45) return anthropicMessages(model, { type: "budget_tokens", min: 1024 }) + const thinking = opus45 ? manualThinking(model) : { settings: { thinking: ADAPTIVE_THINKING } } + if (!thinking) return [] + const defaults = info.major === 4 && info.minor === 6 ? [...EFFORTS, "max"] : [...EFFORTS, "xhigh", "max"] + const values = support.values ?? defaults + return efforts(values, (effort) => ({ + settings: { ...thinking.settings, effort }, + })) + } + case "toggle": { + if (info.always) return [] + const thinking = info.manual ? manualThinking(model) : { settings: { thinking: ADAPTIVE_THINKING } } + if (!thinking) return [] + return toggle({ settings: { thinking: { type: "disabled" } } }, thinking) + } + case "budget_tokens": + return budgets(model, support, (tokens) => ({ + settings: { thinking: { type: "enabled", budgetTokens: tokens } }, + })) + } +} + +const minimaxMessages: Protocol = (model, support) => { + if (/minimax[-.]?m2(?:[.-]|$)/i.test(modelID(model))) return [] + const configurable = support.type === "toggle" || (support.type === "effort" && support.values === undefined) + if (!configurable) return [] + return toggle({ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: { type: "adaptive" } } }) +} + +const moonshotMessages: Protocol = (model, support) => { + if (support.type !== "effort" || /kimi[-.]?k2/i.test(modelID(model))) return [] + return efforts(support.values ?? ["low", "high", "max"], (effort) => ({ settings: { effort } })) +} + +const alibabaMessages: Protocol = (model, support) => { + const id = modelID(model).toLowerCase() + switch (support.type) { + case "effort": { + const hosted = id.includes("glm") || id.includes("deepseek") + const values = support.values ?? (hosted ? ["high", "max"] : ["low", "medium", "xhigh"]) + return efforts(values, (effort) => { + if (effort === "none") return { settings: { thinking: { type: "disabled" } } } + return { settings: { thinking: { type: "enabled" }, effort } } + }) + } + case "toggle": + return toggle({ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: { type: "enabled" } } }) + case "budget_tokens": + return budgets(model, support, (tokens) => ({ + settings: { thinking: { type: "enabled", budgetTokens: tokens } }, + })) + } +} + +const zaiMessages: Protocol = (model, support) => { + const id = modelID(model).toLowerCase() + const forced = id.includes("glm-5.3") || id.includes("glm-5-3") || id.includes("glm-5p3") + switch (support.type) { + case "effort": + return efforts(support.values ?? (forced ? ["low", "high", "max"] : ["high", "max"]), (effort) => ({ + settings: { + thinking: { type: effort === "none" || effort === "minimal" ? "disabled" : "enabled" }, + effort, + }, + })) + case "toggle": + return forced + ? [] + : toggle({ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: { type: "enabled" } } }) + case "budget_tokens": + return [] + } +} + +const gemini: Protocol = (model, support) => { + switch (support.type) { + case "effort": + return efforts(support.values ?? EFFORTS, (effort) => ({ + settings: { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }, + })) + case "toggle": + return toggle( + { settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } }, + { settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } } }, + ) + case "budget_tokens": + return budgets(model, support, (tokens) => ({ + settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: tokens } }, + })) + } +} + +const openrouter: Protocol = (model, support) => { + switch (support.type) { + case "effort": + return efforts(support.values ?? EFFORTS, (effort) => ({ settings: { reasoning: { effort } } })) + case "toggle": + return toggle({ settings: { reasoning: { enabled: false } } }, { settings: { reasoning: { enabled: true } } }) + case "budget_tokens": + return budgets(model, support, (tokens) => ({ settings: { reasoning: { max_tokens: tokens } } })) + } +} + +const bedrockConverse: Protocol = (model, support) => { + const id = modelID(model) + const claude = id.includes("anthropic") + const fields = (fields: Record): Overlay => ({ body: { additionalModelRequestFields: fields } }) + switch (support.type) { + case "effort": + return efforts(support.values ?? EFFORTS, (effort) => { + if (claude) + return fields({ + ...(claudeInfo(model).manual ? {} : { thinking: ADAPTIVE_THINKING }), + output_config: { effort }, + }) + if (id.includes("openai.gpt-oss")) return fields({ reasoning_effort: effort }) + if (id.includes("openai.")) return fields({ reasoning: { effort } }) + return fields({ reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }) + }) + case "toggle": + return claude + ? toggle(fields({ thinking: { type: "disabled" } }), fields({ thinking: ADAPTIVE_THINKING })) + : toggle(fields({ reasoningConfig: { type: "disabled" } }), fields({ reasoningConfig: { type: "enabled" } })) + case "budget_tokens": + return budgets(model, support, (tokens) => + claude + ? fields({ thinking: { type: "enabled", budget_tokens: tokens } }) + : fields({ reasoningConfig: { type: "enabled", budgetTokens: tokens } }), + ) + } +} + +const alibabaAISDK: Protocol = (model, support) => { + switch (support.type) { + case "effort": + return [] + case "toggle": + return toggle({ settings: { enableThinking: false } }, { settings: { enableThinking: true } }) + case "budget_tokens": + return budgets(model, support, (tokens) => ({ settings: { enableThinking: true, thinkingBudget: tokens } })) + } +} + +const cohere: Protocol = (model, support) => { + switch (support.type) { + case "effort": + return [] + case "toggle": + return toggle({ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: { type: "enabled" } } }) + case "budget_tokens": + return budgets(model, support, (tokens) => ({ settings: { thinking: { type: "enabled", tokenBudget: tokens } } })) + } +} + +const bedrockAISDK: Protocol = (model, support) => { + const claude = modelID(model).includes("anthropic") + switch (support.type) { + case "effort": + return efforts(support.values ?? EFFORTS, (effort) => ({ + settings: claude + ? { + reasoningConfig: { + ...(claudeInfo(model).manual ? {} : ADAPTIVE_THINKING), + maxReasoningEffort: effort, + }, + } + : { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }, + })) + case "toggle": + return claude + ? toggle( + { settings: { additionalModelRequestFields: { thinking: { type: "disabled" } } } }, + { settings: { additionalModelRequestFields: { thinking: ADAPTIVE_THINKING } } }, + ) + : toggle( + { settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } } }, + { settings: { additionalModelRequestFields: { reasoningConfig: { type: "enabled" } } } }, + ) + case "budget_tokens": + return budgets(model, support, (tokens) => ({ + settings: { reasoningConfig: { type: "enabled", budgetTokens: tokens } }, + })) + } +} + +const vercelGateway: Protocol = (model, support) => { + const prefix = modelID(model).split("/")[0] + if (prefix === "anthropic") return anthropicMessages(model, support) + if (prefix === "google") return gemini(model, support) + if (prefix === "amazon") return bedrockAISDK(model, support) + if (prefix === "alibaba") return alibabaAISDK(model, support) + return support.type === "effort" ? openaiChat(model, support) : openrouter(model, support) +} + +const sapAICore: Protocol = (model, support) => { + const id = modelID(model) + const sap = (modelParams: Record): Overlay => ({ settings: { modelParams } }) + switch (support.type) { + case "effort": + return efforts(support.values ?? EFFORTS, (effort) => { + if (id.includes("anthropic")) + return sap({ + additionalModelRequestFields: { + ...(claudeInfo(model).manual ? {} : { thinking: ADAPTIVE_THINKING }), + output_config: { effort }, + }, + }) + if (id.includes("gemini")) return sap({ thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }) + if (id.includes("amazon--nova")) return sap({ additionalModelRequestFields: { output_config: { effort } } }) + return sap({ reasoning_effort: effort }) + }) + case "toggle": + if (id.includes("gemini")) + return toggle( + sap({ thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } }), + sap({ thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } }), + ) + if (id.includes("cohere")) + return toggle(sap({ thinking: { type: "disabled" } }), sap({ thinking: { type: "enabled" } })) + if (id.includes("amazon--nova")) + return toggle( + sap({ additionalModelRequestFields: { thinking: { type: "disabled" } } }), + sap({ additionalModelRequestFields: { thinking: { type: "enabled" } } }), + ) + if (id.includes("anthropic")) + return toggle( + sap({ additionalModelRequestFields: { thinking: { type: "disabled" } } }), + sap({ additionalModelRequestFields: { thinking: ADAPTIVE_THINKING } }), + ) + return [] + case "budget_tokens": + if (id.includes("anthropic")) + return budgets(model, support, (tokens) => + sap({ additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: tokens } } }), + ) + if (id.includes("gemini")) + return budgets(model, support, (tokens) => + sap({ thinkingConfig: { includeThoughts: true, thinkingBudget: tokens } }), + ) + if (id.includes("cohere")) + return budgets(model, support, (tokens) => sap({ thinking: { type: "enabled", token_budget: tokens } })) + return [] + } +} + +const PROTOCOLS: Readonly> = { + "@opencode/ai/providers/openai": openaiResponses, + "@opencode/ai/providers/azure/responses": openaiResponses, + "@opencode/ai/providers/amazon-bedrock/mantle/chat": openaiResponses, + "@opencode/ai/providers/amazon-bedrock/mantle/responses": openaiResponses, + "@opencode/ai/providers/alibaba/responses": openaiResponses, + "@opencode/ai/providers/meta/responses": openaiResponses, + "@opencode/ai/providers/minimax/responses": openaiResponses, + "@opencode/ai/providers/moonshot/responses": openaiResponses, + "@opencode/ai/providers/zai-coding-plan/responses": openaiResponses, + + "@opencode/ai/providers/openai-compatible": openaiCompatible, + "@opencode/ai/providers/google-vertex/chat": openaiChat, + "@opencode/ai/providers/alibaba/chat": alibabaChat, + "@opencode/ai/providers/baseten": basetenChat, + "@opencode/ai/providers/cerebras": openaiChat, + "@opencode/ai/providers/cloudflare-workers-ai": openaiChat, + "@opencode/ai/providers/deepinfra": deepinfraChat, + "@opencode/ai/providers/deepseek": deepseekChat, + "@opencode/ai/providers/fireworks": openaiChat, + "@opencode/ai/providers/groq": openaiChat, + "@opencode/ai/providers/meta/chat": openaiChat, + "@opencode/ai/providers/minimax/chat": openaiChat, + "@opencode/ai/providers/mistral": openaiChat, + "@opencode/ai/providers/moonshot/chat": moonshotChat, + "@opencode/ai/providers/togetherai": openaiChat, + "@opencode/ai/providers/xai": openaiChat, + "@opencode/ai/providers/zai/chat": zaiChat, + "@opencode/ai/providers/zai-coding-plan/chat": zaiChat, + + "@opencode/ai/providers/anthropic": anthropicMessages, + "@opencode/ai/providers/google-vertex/messages": anthropicMessages, + "@opencode/ai/providers/alibaba/messages": alibabaMessages, + "@opencode/ai/providers/meta/messages": anthropicMessages, + "@opencode/ai/providers/minimax/messages": minimaxMessages, + "@opencode/ai/providers/moonshot/messages": moonshotMessages, + "@opencode/ai/providers/zai-coding-plan/messages": zaiMessages, + + "@opencode/ai/providers/google": gemini, + "@opencode/ai/providers/google-vertex": gemini, + + "@opencode/ai/providers/amazon-bedrock": bedrockConverse, + "@opencode/ai/providers/openrouter": openrouter, + + [Provider.aisdk("venice-ai-sdk-provider")]: openaiChat, + "@opencode/ai/providers/cloudflare-ai-gateway": cloudflareAIGateway, + [Provider.aisdk("@ai-sdk/gateway")]: vercelGateway, + [Provider.aisdk("@jerome-benoit/sap-ai-provider-v2")]: sapAICore, + [Provider.aisdk("@ai-sdk/cohere")]: cohere, +} diff --git a/packages/core/test/aisdk-native.test.ts b/packages/core/test/aisdk-native.test.ts index 11c657a3396d..998dd1f12316 100644 --- a/packages/core/test/aisdk-native.test.ts +++ b/packages/core/test/aisdk-native.test.ts @@ -1,10 +1,39 @@ import { describe, expect, test } from "bun:test" import { AISDKNative } from "@opencode/core/aisdk-native" -const map = (packageName: string, settings: Readonly>, modelID = "test-model") => - AISDKNative.map({ packageName, settings, modelID, providerID: "test-provider" }) +function map( + packageName: string, + settings: Readonly>, + modelID = "test-model", + providerID = "test-provider", +) { + const target: { + package?: string + settings?: Record + headers?: Record + body?: Record + } = { + package: `aisdk:${packageName}`, + settings: { ...settings }, + } + AISDKNative.rewrite(target, { specifier: target.package, providerID, modelID }) + return target.package?.startsWith("aisdk:") ? undefined : target +} describe("AISDKNative", () => { + test("keeps Cloudflare AI Gateway models on its native gateway package", () => { + for (const packageName of [ + "ai-gateway-provider", + "@ai-sdk/openai", + "@ai-sdk/anthropic", + "@ai-sdk/openai-compatible", + ]) { + expect(map(packageName, {}, "openai/gpt-5.4", "cloudflare-ai-gateway")?.package).toBe( + "@opencode/ai/providers/cloudflare-ai-gateway", + ) + } + }) + test("maps OpenAI-family packages and request options to native providers", () => { expect( map("@ai-sdk/openai", { diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index ec362a6740b3..c1afc35b46b2 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -1,6 +1,5 @@ import { APICallError } from "@ai-sdk/provider" import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider" -import { createMistral } from "@ai-sdk/mistral" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" import { AISDK } from "@opencode/core/aisdk" import { SessionRunnerRetry } from "@opencode/core/session/runner/retry" @@ -533,118 +532,6 @@ it.effect("normalizes file data across AI SDK prompt parts", () => }), ) -it.effect("normalizes user and tool media through the real Mistral provider", () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - let body: { messages?: unknown[] } | undefined - const mockFetch = Object.assign( - async (_input: Parameters[0], init?: RequestInit) => { - body = JSON.parse(String(init?.body)) - const chunks = [ - { - id: "response-1", - created: 0, - model: "pixtral-large-latest", - choices: [{ index: 0, delta: { content: [{ type: "text", text: "I see it." }] } }], - }, - { - id: "response-1", - created: 0, - model: "pixtral-large-latest", - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ] - return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), { - headers: { "Content-Type": "text/event-stream" }, - }) - }, - { preconnect: fetch.preconnect }, - ) - yield* aisdk.hook.sdk((event) => { - event.sdk = createMistral({ apiKey: "test", fetch: mockFetch }) - }) - - const resolved = yield* aisdk.model({ - ...model("@ai-sdk/mistral"), - modelID: Model.ID.make("pixtral-large-latest"), - }) - yield* LLMClient.generate( - LLM.request({ - model: resolved, - messages: [ - Message.user([ - { type: "text", text: "Inspect the attachments." }, - { type: "media", mediaType: "image/png", data: new Uint8Array([0, 1, 2, 3]) }, - { type: "media", mediaType: "image/png", data: "AQID" }, - { type: "media", mediaType: "image/png", data: "data:image/png;base64,BAUG" }, - { type: "media", mediaType: "image/png", data: "http://example.com/image.png" }, - { type: "media", mediaType: "application/pdf", data: "https://example.com/document.pdf" }, - ]), - Message.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }), - Message.tool({ - type: "tool-result", - id: "call_1", - name: "screenshot", - result: { - type: "content", - value: [ - { type: "text", text: "Screenshot captured" }, - { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "screen.png" }, - { - type: "file", - uri: "https://example.com/tool-document.pdf", - mime: "application/pdf", - name: "tool-document.pdf", - }, - ], - }, - }), - ], - }), - ).pipe(Effect.provide(client)) - - expect(body?.messages).toEqual([ - { - role: "user", - content: [ - { type: "text", text: "Inspect the attachments." }, - { type: "image_url", image_url: "data:image/png;base64,AAECAw==" }, - { type: "image_url", image_url: "data:image/png;base64,AQID" }, - { type: "image_url", image_url: "data:image/png;base64,BAUG" }, - { type: "image_url", image_url: "http://example.com/image.png" }, - { type: "document_url", document_url: "https://example.com/document.pdf" }, - ], - }, - { - role: "assistant", - content: "", - tool_calls: [ - { - id: "call_1", - type: "function", - function: { name: "screenshot", arguments: "{}" }, - }, - ], - }, - { - role: "tool", - name: "screenshot", - tool_call_id: "call_1", - content: '[{"type":"text","text":"Screenshot captured"}]', - }, - { - role: "user", - content: [ - { type: "text", text: "Attached media from tool result:" }, - { type: "image_url", image_url: "data:image/png;base64,AAAA" }, - { type: "document_url", document_url: "https://example.com/tool-document.pdf" }, - ], - }, - ]) - }), -) - it.effect("does not treat SSE comment heartbeats as model progress", () => Effect.gen(function* () { const aisdk = yield* AISDK.Service diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 2887d48c9813..61fc7e75ff7a 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -244,13 +244,13 @@ describe("Provider and Model", () => { const providerID = Provider.ID.make("test") yield* providers.transform((editor) => editor.update(providerID, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = Provider.aisdk("custom-sdk") provider.settings = { baseURL: "https://override.example.com" } }), ) expect(required(yield* providers.get(providerID))).toMatchObject({ - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: Provider.aisdk("custom-sdk"), settings: { baseURL: "https://override.example.com" }, }) }), @@ -264,21 +264,21 @@ describe("Provider and Model", () => { const modelID = Model.ID.make("model") yield* providers.transform((editor) => { editor.update(providerID, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = Provider.aisdk("custom-sdk") provider.settings = { baseURL: "https://provider.example.com" } }) }) yield* models.transform((editor) => { editor.update(providerID, modelID, (model) => { model.modelID = Model.ID.make("upstream-model") - model.package = Provider.aisdk("@ai-sdk/openai-compatible") + model.package = Provider.aisdk("custom-sdk") model.settings = { baseURL: "https://override.example.com" } }) }) expect(required(yield* models.get(providerID, modelID))).toMatchObject({ modelID: Model.ID.make("upstream-model"), - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: Provider.aisdk("custom-sdk"), settings: { baseURL: "https://override.example.com" }, }) }), @@ -292,19 +292,75 @@ describe("Provider and Model", () => { const modelID = Model.ID.make("model") yield* providers.transform((editor) => { editor.update(providerID, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = Provider.aisdk("custom-sdk") provider.settings = { baseURL: "https://provider.example.com" } }) editor.models.update(providerID, modelID, () => {}) }) expect(required(yield* models.get(providerID, modelID))).toMatchObject({ - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: Provider.aisdk("custom-sdk"), settings: { baseURL: "https://provider.example.com" }, }) }), ) + it.effect("rewrites AI SDK packages and their options on write", () => + Effect.gen(function* () { + const providers = yield* Provider.Service + const models = yield* Model.Service + const providerID = Provider.ID.make("bedrock") + const modelID = Model.ID.make("claude") + yield* providers.transform((editor) => { + editor.update(providerID, (provider) => { + provider.package = Provider.aisdk("@ai-sdk/amazon-bedrock") + provider.settings = { region: "us-east-1", extraBody: { tag: "provider" } } + }) + editor.models.update(providerID, modelID, () => {}) + editor.update(Provider.ID.make("gitlab"), (provider) => { + provider.package = Provider.aisdk("@ai-sdk/gitlab") + provider.settings = { extraBody: { kept: true } } + }) + }) + yield* models.transform((editor) => { + editor.update(providerID, modelID, (model) => { + model.modelID = Model.ID.make("anthropic.claude-opus-4-8") + model.package = Provider.aisdk("@ai-sdk/amazon-bedrock") + model.settings = { reasoningConfig: { type: "adaptive", maxReasoningEffort: "high" } } + model.variants = [ + { + id: Model.VariantID.make("max"), + settings: { reasoningConfig: { type: "adaptive", maxReasoningEffort: "max" } }, + }, + ] + }) + }) + + expect(required(yield* providers.get(providerID))).toMatchObject({ + package: "@opencode/ai/providers/amazon-bedrock", + settings: { region: "us-east-1" }, + body: { tag: "provider" }, + }) + const model = required(yield* models.get(providerID, modelID)) + expect(model.package).toBe("@opencode/ai/providers/amazon-bedrock") + expect(model.settings).not.toHaveProperty("reasoningConfig") + expect(model.body).toMatchObject({ + additionalModelRequestFields: { thinking: { type: "adaptive" }, output_config: { effort: "high" } }, + }) + expect(model.variants).toEqual([ + { + id: Model.VariantID.make("max"), + settings: {}, + body: { additionalModelRequestFields: { thinking: { type: "adaptive" }, output_config: { effort: "max" } } }, + }, + ]) + expect(required(yield* providers.get(Provider.ID.make("gitlab")))).toMatchObject({ + package: Provider.aisdk("@ai-sdk/gitlab"), + settings: { extraBody: { kept: true } }, + }) + }), + ) + it.effect("resolves provider and model overlay merges", () => Effect.gen(function* () { const providers = yield* Provider.Service diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index d3083d3a0846..4ac70d0ec851 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -374,6 +374,40 @@ describe("ConfigProviderPlugin.Plugin", () => { }), ) + it.effect("generates variants after rewriting a configured model package", () => + Effect.gen(function* () { + const models = yield* Model.Service + yield* addPlugin([ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai", + models: { + claude: { + modelID: "claude-opus-4-8", + package: "aisdk:@ai-sdk/anthropic", + }, + }, + }, + }, + }), + }), + ]) + + const model = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("claude"))) + expect(model.package).toBe("@opencode/ai/providers/anthropic") + expect(model.variants.map((variant) => variant.id)).toEqual([ + Model.VariantID.make("low"), + Model.VariantID.make("medium"), + Model.VariantID.make("high"), + Model.VariantID.make("xhigh"), + Model.VariantID.make("max"), + ]) + }), + ) + it.effect("keeps configured model variant bodies unchanged", () => Effect.gen(function* () { const models = yield* Model.Service diff --git a/packages/core/test/github-copilot/models.test.ts b/packages/core/test/github-copilot/models.test.ts index b08a4f6e194c..0080cf0d9335 100644 --- a/packages/core/test/github-copilot/models.test.ts +++ b/packages/core/test/github-copilot/models.test.ts @@ -105,7 +105,7 @@ test("defensively syncs advertised Copilot models", async () => { Model.VariantID.make("high"), ]) expect(model?.capabilities.input).toEqual(["text", "image", "pdf"]) - expect(models.get(Model.ID.make("claude-sonnet"))?.package).toBe(Provider.aisdk("@ai-sdk/anthropic")) + expect(models.get(Model.ID.make("claude-sonnet"))?.package).toBe("@opencode/ai/providers/anthropic") expect(models.get(Model.ID.make("claude-sonnet"))?.settings).toMatchObject({ baseURL: `${server.url.origin}/v1`, endpoint: "messages", diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index f036be057890..718385c2ac33 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -658,7 +658,7 @@ describe("LocationServiceMap", () => { const modelState = yield* Model.Service yield* providers.transform((editor) => { editor.update(Provider.ID.make("aliased"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai") + provider.package = "@opencode/ai/providers/openai" }) editor.models.update(Provider.ID.make("aliased"), Model.ID.make("fast"), (model) => { // Catalog id and package model id intentionally differ, like gpt-5.5-fast -> gpt-5.5. diff --git a/packages/core/test/modal/models.test.ts b/packages/core/test/modal/models.test.ts index fb12f0446cbe..07518f370c96 100644 --- a/packages/core/test/modal/models.test.ts +++ b/packages/core/test/modal/models.test.ts @@ -65,7 +65,7 @@ test("maps live Modal models onto catalog templates", async () => { expect(model?.family).toBe(Model.Family.make("catalog-family")) expect(model?.providerID).toBe(providerID) expect(model?.modelID).toBe(Model.ID.make("live-model")) - expect(model?.package).toBe(Provider.aisdk("@ai-sdk/openai-compatible")) + expect(model?.package).toBe("@opencode/ai/providers/openai-compatible") expect(model?.settings).toMatchObject({ baseURL: `${server.url.origin}/v1` }) expect(model?.compatibility).toMatchObject({ reasoningField: "reasoning_content" }) expect(model?.capabilities).toMatchObject({ tools: true, input: ["text", "image"], output: ["text"] }) diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index f24fb05e5988..29ddfe746e3a 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -4,6 +4,7 @@ import { OpenAIChat } from "@opencode/ai/protocols" import { compileRequest } from "@opencode/ai/route/client" import { ConfigProvider, Effect, Layer } from "effect" import { Headers } from "effect/unstable/http" +import { AISDKNative } from "@opencode/core/aisdk-native" import { Credential } from "@opencode/core/credential" import { Integration } from "@opencode/core/integration" import { Compatibility, ID, Info, Model, VariantID } from "@opencode/core/model" @@ -25,8 +26,8 @@ interface ModelOptions { readonly limit?: Info["limit"] } -const model = (packageName: string | undefined, options: ModelOptions = {}) => - Info.make({ +function model(packageName: string | undefined, options: ModelOptions = {}) { + const info: Model.MutableInfo = { id: ID.make("test-model"), modelID: ID.make(options.modelID ?? "api-test-model"), providerID: options.providerID ?? Provider.ID.make("test-provider"), @@ -38,13 +39,21 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) => headers: options.headers ?? { "x-test": "header" }, body: options.body ?? { custom_extension: { enabled: true } }, capabilities: { tools: true, input: ["text"], output: ["text"] }, - variants: options.variants ?? [], + variants: structuredClone(options.variants ?? []) as Model.MutableInfo["variants"], time: { released: 0 }, cost: [], status: "active", enabled: true, limit: options.limit ?? { context: 100, output: 20 }, + } + AISDKNative.rewrite(info, { + specifier: packageName, + providerID: info.providerID, + canonical: info.canonical, + modelID: info.modelID, }) + return Info.make(info) +} function withEnv(variables: Record, effect: () => Effect.Effect) { return Effect.acquireUseRelease( @@ -131,9 +140,22 @@ describe("ModelResolver", () => { }), ) - it.effect("keeps explicitly selected compatible packages generic for known provider IDs", () => + it.effect("routes compatible packages to the host's dedicated package when one exists", () => Effect.gen(function* () { - for (const providerID of ["baseten", "cerebras", "deepinfra", "deepseek", "fireworks-ai", "groq", "togetherai"]) { + const routes = { + "alibaba-coding-plan-cn": "alibaba-chat", + baseten: "baseten-chat", + deepseek: "deepseek-chat", + "fireworks-ai": "fireworks-chat", + "moonshotai-cn": "moonshot-chat", + zhipuai: "zai-chat", + "zhipuai-coding-plan": "zai-coding-chat", + cerebras: "openai-compatible-chat", + deepinfra: "openai-compatible-chat", + groq: "openai-compatible-chat", + togetherai: "openai-compatible-chat", + } + for (const [providerID, route] of Object.entries(routes)) { const selected = yield* ModelResolver.fromCatalogModel( model(Provider.aisdk("@ai-sdk/openai-compatible"), { providerID: Provider.ID.make(providerID), @@ -141,7 +163,7 @@ describe("ModelResolver", () => { }), ) expect(String(selected.provider)).toBe(providerID) - expect(selected.route.id).toBe("openai-compatible-chat") + expect(selected.route.id).toBe(route) expect(selected.route.endpoint.baseURL).toBe("https://provider.example/v1/openai") const prepared = yield* compileRequest(LLM.request({ model: selected, prompt: "Hello" })) expect(prepared.body.messages).toEqual([{ role: "user", content: "Hello" }]) @@ -214,7 +236,7 @@ describe("ModelResolver", () => { id: "bedrock-mantle-responses", endpoint: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" }, }) - expect(catalog.settings?.baseURL).toBe("https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1") + expect(catalog.settings?.baseURL).toBe("https://bedrock-mantle.us-west-2.api.aws/openai/v1") }), ), ) @@ -1029,6 +1051,7 @@ describe("ModelResolver", () => { it.effect("never loads the AI SDK for packages with native implementations", () => Effect.gen(function* () { const packages = [ + ["@ai-sdk/alibaba", "@opencode/ai/providers/alibaba/chat", "api-model"], ["@ai-sdk/anthropic", "@opencode/ai/providers/anthropic", "api-model"], ["@ai-sdk/amazon-bedrock", "@opencode/ai/providers/amazon-bedrock", "api-model"], ["@ai-sdk/amazon-bedrock/mantle", "@opencode/ai/providers/amazon-bedrock/mantle/chat", "openai.gpt-oss-120b"], @@ -1045,6 +1068,7 @@ describe("ModelResolver", () => { ["@openrouter/ai-sdk-provider", "@opencode/ai/providers/openrouter", "api-model"], ["@ai-sdk/togetherai", "@opencode/ai/providers/togetherai", "api-model"], ["@ai-sdk/xai", "@opencode/ai/providers/xai", "api-model"], + ["ai-gateway-provider", "@opencode/ai/providers/cloudflare-ai-gateway", "xai/grok-4.6"], ] as const yield* Effect.forEach(packages, ([catalogPackage, nativePackage, modelID]) => @@ -1222,6 +1246,16 @@ describe("ModelResolver", () => { settings: { region: "us-east-1", topP: 0.6 }, }), ) + const workers = yield* ModelResolver.fromCatalogModel( + model("@opencode/ai/providers/cloudflare-workers-ai", { + modelID: "@cf/meta/llama-3.1-8b-instruct", + }), + Credential.Key.make({ + type: "key", + key: "workers-secret", + configuration: { accountId: "account" }, + }), + ) expect(google.route.id).toBe("gemini") expect(google.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } }) @@ -1259,6 +1293,7 @@ describe("ModelResolver", () => { expect(bedrock.route.defaults.http?.body).toEqual({ serviceTier: { type: "priority" } }) expect(mantle.route.id).toBe("bedrock-mantle-chat") expect(mantle.route.defaults.generation).toEqual({ topP: 0.6 }) + expect(workers.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/account/ai/v1") }), ) @@ -1401,7 +1436,7 @@ describe("ModelResolver", () => { _tag: "SessionRunnerModel.ModelConfigurationError", providerID: "azure", modelID: "test-model", - package: "aisdk:@ai-sdk/azure", + package: "@opencode/ai/providers/azure/responses", detail: "Azure requires resourceName or baseURL", }) expect(failure.message).toBe("Cannot initialize azure/test-model: Azure requires resourceName or baseURL") diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 1672464ce92c..c0780e57f7a4 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -49,7 +49,7 @@ const fixtureSnapshot = [ id: Provider.ID.make("acme"), name: "Acme", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", }, models: [ { @@ -60,7 +60,7 @@ const fixtureSnapshot = [ compatibility: { reasoningField: "vendor_reasoning" }, family: undefined, package: undefined, - settings: undefined, + settings: { provider: "acme" }, capabilities: { tools: true, input: [], output: [] }, variants: [], time: { released: Date.parse("2026-01-01") }, @@ -112,7 +112,7 @@ const fixture2Snapshot = [ id: Provider.ID.make("beta"), name: "Beta", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", }, models: [ { @@ -122,7 +122,7 @@ const fixture2Snapshot = [ name: "Beta One", family: undefined, package: undefined, - settings: undefined, + settings: { provider: "beta" }, capabilities: { tools: false, input: [], output: [] }, variants: [], time: { released: Date.parse("2026-02-01") }, @@ -234,7 +234,7 @@ describe("ModelsDev Service", () => { }), ) - it.live("normalizes provider and model AI SDK packages from models.dev", () => + it.live("maps models.dev npm packages onto native packages", () => Effect.gen(function* () { const cache = makeCache() writeCache(cache, { @@ -247,6 +247,14 @@ describe("ModelsDev Service", () => { }, }, }, + "cloudflare-workers-ai": { + id: "cloudflare-workers-ai", + name: "Cloudflare Workers AI", + env: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY"], + npm: "@ai-sdk/openai-compatible", + api: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", + models: {}, + }, }) const state = yield* Ref.make(initialState) const result = yield* provided( @@ -254,8 +262,10 @@ describe("ModelsDev Service", () => { cache, ModelsDev.Service.use((service) => service.get()), ) - expect(result[0]?.info.package).toBe(Provider.aisdk("@ai-sdk/openai-compatible")) - expect(result[0]?.models[0]?.package).toBe(Provider.aisdk("@ai-sdk/openai")) + expect(result[0]?.info.package).toBe("@opencode/ai/providers/openai-compatible") + expect(result[0]?.models[0]?.package).toBe("@opencode/ai/providers/openai") + expect(result[1]?.info.package).toBe("@opencode/ai/providers/cloudflare-workers-ai") + expect(result[1]?.info.settings).toBeUndefined() }), ) diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 1db646b4d74e..a571d7760fea 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -75,7 +75,7 @@ const richSnapshot = (name = "Acme") => { id: providerID, name, activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://api.acme.test/v1", thinking: { type: "adaptive", display: "summarized" } }, headers: { "x-acme": "provider" }, body: { service_tier: "default", tags: ["stable"] }, @@ -305,7 +305,7 @@ describe("ModelsDevPlugin", () => { id: providerID, name: "Acme", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", }, environment: [], models: [ @@ -362,7 +362,7 @@ describe("ModelsDevPlugin", () => { id: providerID, name: "Acme", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://api.acme.test/v1" }, headers: { "x-acme": "provider" }, }, @@ -441,7 +441,7 @@ describe("ModelsDevPlugin", () => { id: providerID, name: "Acme", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://api.acme.test/v1" }, }, environment: [], @@ -493,7 +493,7 @@ describe("ModelsDevPlugin", () => { providerID, name: "GPT-5.4 Fast", family: Model.Family.make("gpt"), - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://api.acme.test/v1" }, headers: { "x-mode": "fast" }, body: { service_tier: "priority" }, @@ -556,7 +556,7 @@ describe("ModelsDevPlugin", () => { modelID: "gpt-5.4", providerID: "acme", name: "GPT-5.4 Fast", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://api.acme.test/v1" }, headers: { "x-mode": "fast" }, body: { service_tier: "priority" }, @@ -619,7 +619,7 @@ describe("ModelsDevPlugin", () => { id: providerID, name: "Acme", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", }, environment: [], models: [ @@ -713,7 +713,7 @@ describe("ModelsDevPlugin", () => { id: providerID, name: "Acme", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://${ACME_HOST}/${UNDECLARED_HOST}/v1" }, }, environment: ["ACME_HOST", "ACME_MODEL_PATH", "ACME_API_KEY"], @@ -765,7 +765,7 @@ describe("ModelsDevPlugin", () => { id: providerID, name: "Acme", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", }, environment: [], models: [ @@ -863,7 +863,7 @@ describe("ModelsDevPlugin", () => { }), ) - it.effect("advertises only key-bearing Google Vertex environment variables", () => + it.effect("advertises only credential-bearing environment variables", () => Effect.gen(function* () { const integrations = yield* Integration.Service const providers = yield* Provider.Service @@ -879,12 +879,22 @@ describe("ModelsDevPlugin", () => { ModelsDev.Service.of({ get: () => Effect.succeed([ + { + info: { + id: Provider.ID.make("cloudflare-workers-ai"), + name: "Cloudflare Workers AI", + activation: "auto", + package: "@opencode/ai/providers/cloudflare-workers-ai", + }, + environment: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY"], + models: [], + }, { info: { id: Provider.ID.make("google-vertex"), name: "Google Vertex", activation: "auto", - package: Provider.aisdk("@ai-sdk/google-vertex"), + package: "@opencode/ai/providers/google-vertex", }, environment: ["GOOGLE_VERTEX_PROJECT", "GOOGLE_VERTEX_LOCATION", "GOOGLE_APPLICATION_CREDENTIALS"], models: [], @@ -900,6 +910,24 @@ describe("ModelsDevPlugin", () => { expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toMatchObject({ methods: [{ type: "key" }, { type: "env", names: ["GOOGLE_VERTEX_API_KEY"] }], }) + expect(yield* integrations.get(Integration.ID.make("cloudflare-workers-ai"))).toMatchObject({ + methods: [ + { type: "key" }, + { + type: "env", + names: ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN", "CLOUDFLARE_API_TOKEN"], + }, + ], + }) + yield* withEnv({ CLOUDFLARE_ACCOUNT_ID: "account", CLOUDFLARE_API_KEY: "token" }, () => + integrations.connection + .active(Integration.ID.make("cloudflare-workers-ai")) + .pipe( + Effect.tap((connection) => + Effect.sync(() => expect(connection).toEqual({ type: "env", name: "CLOUDFLARE_API_KEY" })), + ), + ), + ) }), ) @@ -959,11 +987,11 @@ describe("ModelsDevPlugin", () => { const budgetModel = yield* modelState.get(Provider.ID.anthropic, Model.ID.make("claude-budget")) expect(budgetModel?.variants).toContainEqual({ id: Model.VariantID.make("high"), - settings: { thinking: { type: "enabled", budgetTokens: 16000 } }, + settings: { thinking: { type: "enabled", budgetTokens: 32000 } }, }) expect(budgetModel?.variants).toContainEqual({ id: Model.VariantID.make("max"), - settings: { thinking: { type: "enabled", budgetTokens: 31999 } }, + settings: { thinking: { type: "enabled", budgetTokens: 63999 } }, }) const anthropicEffortModel = yield* modelState.get(Provider.ID.anthropic, Model.ID.make("claude-opus-4.7")) @@ -986,8 +1014,14 @@ describe("ModelsDevPlugin", () => { const opus45 = yield* modelState.get(Provider.ID.anthropic, Model.ID.make("claude-opus-4-5")) expect(opus45?.variants).toEqual([ - { id: Model.VariantID.make("low"), settings: { effort: "low" } }, - { id: Model.VariantID.make("high"), settings: { effort: "high" } }, + { + id: Model.VariantID.make("low"), + settings: { effort: "low", thinking: { type: "enabled", budgetTokens: 8191 } }, + }, + { + id: Model.VariantID.make("high"), + settings: { effort: "high", thinking: { type: "enabled", budgetTokens: 8191 } }, + }, ]) const grok = yield* modelState.get(Provider.ID.make("xai"), Model.ID.make("grok-4.5")) @@ -1119,15 +1153,15 @@ describe("ModelsDevPlugin", () => { expect(bedrock?.variants).toEqual([ { id: Model.VariantID.make("none"), - settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } }, + body: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } }, }, { id: Model.VariantID.make("low"), - settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } }, + body: { additionalModelRequestFields: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } } }, }, { id: Model.VariantID.make("high"), - settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }, + body: { additionalModelRequestFields: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } } }, }, ]) diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index b2079cdf922c..f3624e8a674d 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -56,7 +56,7 @@ const seedBedrock = Effect.fn(function* (settings?: Provider.Settings) { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.amazonBedrock, (item) => { - item.package = Provider.aisdk("@ai-sdk/amazon-bedrock") + item.package = "@opencode/ai/providers/amazon-bedrock" if (settings) item.settings = settings }) }) @@ -70,7 +70,7 @@ describe("AmazonBedrockPlugin", () => { const catalog = yield* seedBedrock({ endpoint: "https://bedrock.example" }) yield* addPlugin() const result = required(yield* catalog.get(Provider.ID.amazonBedrock)) - expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock")) + expect(result.package).toBe("@opencode/ai/providers/amazon-bedrock") expect(result.settings).toEqual({ baseURL: "https://bedrock.example", region: "us-east-1" }) }), ), @@ -151,7 +151,7 @@ describe("AmazonBedrockPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.amazonBedrock, (item) => { - item.package = Provider.aisdk("@ai-sdk/amazon-bedrock") + item.package = "@opencode/ai/providers/amazon-bedrock" item.activation = "disabled" }) }) @@ -205,13 +205,13 @@ describe("AmazonBedrockPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("mantle"), (item) => { - item.package = Provider.aisdk("@ai-sdk/amazon-bedrock/mantle") + item.package = "@opencode/ai/providers/amazon-bedrock/mantle/responses" }) catalog.update(Provider.ID.make("native"), (item) => { item.package = "@opencode/ai/providers/amazon-bedrock" }) catalog.update(Provider.ID.make("other"), (item) => { - item.package = Provider.aisdk("@ai-sdk/anthropic") + item.package = "@opencode/ai/providers/anthropic" }) }) yield* addPlugin() diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 4d2ecc462e8b..d9d549761145 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -244,7 +244,7 @@ describe("AzurePlugin", () => { const models = yield* Model.Service yield* catalog.transform((editor) => { editor.update(Provider.ID.azure, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/azure") + provider.package = "@opencode/ai/providers/azure/responses" }) editor.models.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {}) editor.models.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {}) @@ -311,7 +311,7 @@ describe("AzurePlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.azure, (item) => { - item.package = Provider.aisdk("@ai-sdk/azure") + item.package = "@opencode/ai/providers/azure/responses" }) }) yield* addPlugin() @@ -326,7 +326,7 @@ describe("AzurePlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.azure, (item) => { - item.package = Provider.aisdk("@ai-sdk/azure") + item.package = "@opencode/ai/providers/azure/responses" }) }) yield* addPlugin() @@ -342,14 +342,14 @@ describe("AzurePlugin", () => { const models = yield* Model.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.azure, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.activation = "enabled" provider.settings = { baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai", } }) catalog.models.update(Provider.ID.azure, Model.ID.make("anthropic"), (model) => { - model.package = Provider.aisdk("@ai-sdk/anthropic") + model.package = "@opencode/ai/providers/anthropic" model.settings = { resourceName: "model-resource", baseURL: "https://${AZURE_RESOURCE_NAME}.services.ai.azure.com/anthropic/v1", @@ -378,7 +378,7 @@ describe("AzurePlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.azure, (item) => { - item.package = Provider.aisdk("@ai-sdk/azure") + item.package = "@opencode/ai/providers/azure/responses" item.settings = { resourceName: "from-config" } }) catalog.update(Provider.ID.openai, () => {}) @@ -396,7 +396,7 @@ describe("AzurePlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.azure, (item) => { - item.package = Provider.aisdk("@ai-sdk/azure") + item.package = "@opencode/ai/providers/azure/responses" item.settings = { resourceName: "" } }) }) @@ -412,7 +412,7 @@ describe("AzurePlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.azure, (item) => { - item.package = Provider.aisdk("@ai-sdk/azure") + item.package = "@opencode/ai/providers/azure/responses" item.settings = { resourceName: " " } }) }) @@ -437,12 +437,12 @@ describe("AzurePlugin", () => { } yield* catalog.transform((editor) => { editor.update(Provider.ID.azure, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/azure") + provider.package = "@opencode/ai/providers/azure/responses" provider.activation = "enabled" }) editor.models.update(Provider.ID.azure, models.responses, () => {}) editor.models.update(Provider.ID.azure, models.chat, (model) => { - model.settings = { useCompletionUrls: true } + model.package = "@opencode/ai/providers/azure/chat" }) editor.models.update(Provider.ID.azure, models.preview, (model) => { model.settings = { apiVersion: "2025-04-01-preview" } @@ -454,7 +454,7 @@ describe("AzurePlugin", () => { model.settings = { baseURL: "https://gateway.example/azure" } }) editor.models.update(Provider.ID.azure, models.nonAzure, (model) => { - model.package = Provider.aisdk("@ai-sdk/anthropic") + model.package = "@opencode/ai/providers/anthropic" }) }) diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index 3afc1b5cfcc3..02d8979c0a15 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -21,7 +21,7 @@ describe("CerebrasPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("cerebras"), (item) => { - item.package = Provider.aisdk("@ai-sdk/cerebras") + item.package = "@opencode/ai/providers/cerebras" item.headers = { ...item.headers, Existing: "1" } }) }) diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index defb761edb37..1a122d2f6f96 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -1,13 +1,10 @@ -import { AISDK } from "@opencode/core/aisdk" -import { describe, expect, mock } from "bun:test" +import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Model } from "@opencode/core/model" +import { Integration } from "@opencode/core/integration" import { Plugin } from "@opencode/core/plugin" import { PluginHost } from "@opencode/core/plugin/host" import { CloudflareAIGatewayPlugin } from "@opencode/core/plugin/provider/cloudflare-ai-gateway" -import { Provider } from "@opencode/core/provider" import { withEnv } from "../fixture/env" -import { Integration } from "@opencode/core/integration" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -19,68 +16,6 @@ const addPlugin = Effect.fn(function* () { yield* CloudflareAIGatewayPlugin.effect(host) }) -const aiGatewayCalls: Record[] = [] -const unifiedCalls: string[] = [] -const gatewayModelCalls: unknown[] = [] - -function captureAiGatewayOptions(options: Record) { - const nested = - options.options && typeof options.options === "object" ? (options.options as Record) : undefined - return { - ...options, - ...(nested - ? { - options: { - ...nested, - headers: - nested.headers && typeof nested.headers === "object" - ? { ...(nested.headers as Record) } - : nested.headers, - }, - } - : {}), - } -} - -function resetCalls() { - aiGatewayCalls.length = 0 - unifiedCalls.length = 0 - gatewayModelCalls.length = 0 -} - -function cloudflareEnv(overrides: Record = {}) { - return { - CLOUDFLARE_ACCOUNT_ID: "env-account", - CLOUDFLARE_GATEWAY_ID: "env-gateway", - CLOUDFLARE_API_TOKEN: "env-token", - CF_AIG_TOKEN: undefined, - ...overrides, - } -} - -mock.module("ai-gateway-provider", () => ({ - createAiGateway(options: Record) { - aiGatewayCalls.push(captureAiGatewayOptions(options)) - return (input: unknown) => { - gatewayModelCalls.push(input) - return { - modelId: input, - provider: "cloudflare-ai-gateway", - specificationVersion: "v3", - } - } - }, -})) - -mock.module("ai-gateway-provider/providers/unified", () => ({ - createUnified() { - return (modelID: string) => { - unifiedCalls.push(modelID) - return { unifiedModelID: modelID } - } - }, -})) - describe("CloudflareAIGatewayPlugin", () => { it.effect("registers account and gateway forms when the environment does not provide them", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }, () => @@ -99,331 +34,15 @@ describe("CloudflareAIGatewayPlugin", () => { ), ) - it.effect("requires account, gateway, and token before creating the unified SDK", () => - withEnv( - { - CLOUDFLARE_ACCOUNT_ID: "acct", - CLOUDFLARE_GATEWAY_ID: "gateway", - CLOUDFLARE_API_TOKEN: "token", - CF_AIG_TOKEN: undefined, - }, - () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }) - expect(result.sdk.languageModel("openai/gpt-5")).toBeDefined() - }), - ), - ) - - it.effect("passes legacy metadata, cache, log, and User-Agent values under the AI Gateway options key", () => - withEnv(cloudflareEnv(), () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - metadata: { invoked_by: "test", project: "opencode" }, - cacheTtl: 300, - cacheKey: "cache-key", - skipCache: true, - collectLog: false, - }, - }) - - expect(aiGatewayCalls).toHaveLength(1) - expect(aiGatewayCalls[0]).toEqual({ - accountId: "env-account", - gateway: "env-gateway", - apiKey: "env-token", - options: { - metadata: { invoked_by: "test", project: "opencode" }, - cacheTtl: 300, - cacheKey: "cache-key", - skipCache: true, - collectLog: false, - headers: { - "User-Agent": expect.stringContaining("opencode/"), - }, - }, - }) - }), - ), - ) - - it.effect("parses legacy cf-aig-metadata header when metadata option is absent", () => - withEnv(cloudflareEnv(), () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - headers: { - "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }), - }, - }, - }) - - expect(aiGatewayCalls[0]?.options).toMatchObject({ - metadata: { invoked_by: "header", project: "opencode" }, - }) - }), - ), - ) - - it.effect("prefers Cloudflare env values over auth/config-derived options", () => - withEnv(cloudflareEnv(), () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - accountId: "auth-account", - gateway: "auth-gateway", - apiKey: "auth-token", - }, - }) - - expect(aiGatewayCalls[0]).toMatchObject({ - accountId: "env-account", - gateway: "env-gateway", - apiKey: "env-token", - }) - }), - ), - ) - - it.effect("accepts gatewayId metadata copied from auth into provider options", () => - withEnv( - cloudflareEnv({ - CLOUDFLARE_ACCOUNT_ID: undefined, - CLOUDFLARE_GATEWAY_ID: undefined, - CLOUDFLARE_API_TOKEN: undefined, - }), - () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - accountId: "auth-account", - gatewayId: "auth-gateway", - apiKey: "auth-token", - }, - }) - - expect(aiGatewayCalls[0]).toMatchObject({ - accountId: "auth-account", - gateway: "auth-gateway", - apiKey: "auth-token", - }) - }), - ), - ) - - it.effect("falls back to CF_AIG_TOKEN when CLOUDFLARE_API_TOKEN is unset", () => - withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: "cf-aig-token" }), () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }) - - expect(aiGatewayCalls[0]).toMatchObject({ apiKey: "cf-aig-token" }) - }), - ), - ) - - it.effect("does not create an SDK when account and gateway IDs are missing", () => - withEnv(cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }), () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }) - - expect(result.sdk).toBeUndefined() - expect(aiGatewayCalls).toHaveLength(0) - }), - ), - ) - - it.effect("does not create an SDK when the token is missing", () => - withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: undefined }), () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }) - - expect(result.sdk).toBeUndefined() - expect(aiGatewayCalls).toHaveLength(0) - }), - ), - ) - - it.effect("does not replace a configured baseURL with the Cloudflare AI Gateway SDK", () => - withEnv( - cloudflareEnv({ - CLOUDFLARE_ACCOUNT_ID: undefined, - CLOUDFLARE_GATEWAY_ID: undefined, - CLOUDFLARE_API_TOKEN: undefined, - }), - () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - const catalog = yield* Provider.Service - yield* catalog.transform((catalog) => - catalog.update(Provider.ID.make("cloudflare-ai-gateway"), (provider) => { - provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" } - }), - ) - yield* addPlugin() - const integrations = yield* Integration.Service - expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({ - type: "key", - label: "Gateway API token", - }) - - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, - }) - - expect(result.sdk).toBeUndefined() - expect(aiGatewayCalls).toHaveLength(0) - }), - ), - ) - - it.effect("maps provider/model IDs through the unified Cloudflare provider unchanged", () => - withEnv(cloudflareEnv(), () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default( - Provider.ID.make("cloudflare-ai-gateway"), - Model.ID.make("anthropic/claude-sonnet-4-5"), - ), - modelID: Model.ID.make("anthropic/claude-sonnet-4-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }) - - expect(result.sdk.languageModel("anthropic/claude-sonnet-4-5")).toEqual({ - modelId: { unifiedModelID: "anthropic/claude-sonnet-4-5" }, - provider: "cloudflare-ai-gateway", - specificationVersion: "v3", - }) - expect(unifiedCalls).toEqual(["anthropic/claude-sonnet-4-5"]) - expect(gatewayModelCalls).toEqual([{ unifiedModelID: "anthropic/claude-sonnet-4-5" }]) - }), - ), - ) - - it.effect("ignores non Cloudflare AI Gateway packages", () => - withEnv(cloudflareEnv(), () => + it.effect("uses environment account and gateway values without requesting form fields", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "account", CLOUDFLARE_GATEWAY_ID: "gateway" }, () => Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service yield* addPlugin() - - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-ai-gateway" }, + const integrations = yield* Integration.Service + expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({ + type: "key", + label: "Gateway API token", }) - - expect(result.sdk).toBeUndefined() - expect(aiGatewayCalls).toHaveLength(0) }), ), ) diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 68bd651e3986..178d62104ac4 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -1,17 +1,15 @@ -import { AISDK } from "@opencode/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Model } from "@opencode/core/model" import { Plugin } from "@opencode/core/plugin" import { PluginHost } from "@opencode/core/plugin/host" import { CloudflareWorkersAIPlugin } from "@opencode/core/plugin/provider/cloudflare-workers-ai" import { Provider } from "@opencode/core/provider" import { Integration } from "@opencode/core/integration" -import { fakeSelectorSdk } from "../fixture/selector" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" const it = testEffect(PluginTestLayer) +const providerID = Provider.ID.make("cloudflare-workers-ai") const addPlugin = Effect.fn(function* () { const plugin = yield* Plugin.Service @@ -45,24 +43,11 @@ function withEnv(vars: Record, effect: () = ) } -function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { - return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( - modelID, - ) -} - -type CloudflareConfig = { - url: (input: { path: string; modelId: string }) => string - headers: () => Record | Promise> -} - -function cloudflareURL(sdk: unknown, modelID = "@cf/model") { - return cloudflareLanguage(sdk, modelID).config.url({ path: "/chat/completions", modelId: modelID }) -} - -function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") { - return cloudflareLanguage(sdk, modelID).config.headers() -} +const seed = Effect.fn(function* (fn: (provider: Provider.MutableInfo) => void) { + const catalog = yield* Provider.Service + yield* catalog.transform((catalog) => catalog.update(providerID, fn)) + return catalog +}) describe("CloudflareWorkersAIPlugin", () => { it.effect("registers an account form when the environment does not provide one", () => @@ -87,199 +72,65 @@ describe("CloudflareWorkersAIPlugin", () => { ), ) - it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () => + it.effect("routes the OpenAI-compatible package to the native Workers AI package", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { - const aisdk = yield* AISDK.Service - const catalog = yield* Provider.Service - yield* catalog.transform((catalog) => - catalog.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => { - provider.package = Provider.aisdk("test-provider") - }), - ) + const catalog = yield* seed((provider) => { + provider.package = "@opencode/ai/providers/cloudflare-workers-ai" + }) yield* addPlugin() const integrations = yield* Integration.Service expect((yield* integrations.get(Integration.ID.make("cloudflare-workers-ai")))?.methods).toContainEqual({ type: "key", label: "API key", }) - const provider = required(yield* catalog.get(Provider.ID.make("cloudflare-workers-ai"))) - const sdk = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")), - modelID: Model.ID.make("@cf/model"), - package: provider.package, - settings: provider.settings, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, - }) - expect(provider).toMatchObject({ - package: "aisdk:test-provider", - settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" }, - }) - expect(sdk.sdk).toBeDefined() + expect(required(yield* catalog.get(providerID)).package).toBe( + "@opencode/ai/providers/cloudflare-workers-ai", + ) }), ), ) - it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () => + it.effect("gives the native Workers AI package its account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => Effect.gen(function* () { - const catalog = yield* Provider.Service - yield* catalog.transform((catalog) => - catalog.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => { - provider.package = Provider.aisdk("test-provider") - provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" } - }), - ) - yield* addPlugin() - expect(required(yield* catalog.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({ - package: "aisdk:test-provider", - settings: { baseURL: "https://proxy.example/v1" }, + const catalog = yield* seed((provider) => { + provider.package = "@opencode/ai/providers/cloudflare-workers-ai" }) - }), - ), - ) - - it.effect("allows a configured baseURL without account ID", () => - withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - const catalog = yield* Provider.Service - yield* catalog.transform((catalog) => - catalog.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => { - provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" } - }), - ) yield* addPlugin() - const integrations = yield* Integration.Service - expect((yield* integrations.get(Integration.ID.make("cloudflare-workers-ai")))?.methods).toContainEqual({ - type: "key", - label: "API key", - }) - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")), - modelID: Model.ID.make("@cf/model"), - package: "aisdk:@ai-sdk/openai-compatible", - settings: { baseURL: "https://proxy.example/v1" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, - }) - expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions") + expect(required(yield* catalog.get(providerID)).settings).toEqual({ accountId: "acct" }) }), ), ) - it.effect("uses env account ID over configured account ID", () => - withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => + it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => Effect.gen(function* () { - const catalog = yield* Provider.Service - yield* catalog.transform((catalog) => - catalog.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => { - provider.package = Provider.aisdk("test-provider") - provider.settings = { ...provider.settings, accountId: "configured-acct" } - }), - ) + const catalog = yield* seed((provider) => { + provider.package = Provider.aisdk("test-provider") + provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" } + }) yield* addPlugin() - expect(required(yield* catalog.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({ + expect(required(yield* catalog.get(providerID))).toMatchObject({ package: "aisdk:test-provider", - settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" }, + settings: { baseURL: "https://proxy.example/v1" }, }) }), ), ) - it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () => - withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => + it.effect("uses env account ID over configured account ID", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => Effect.gen(function* () { - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")), - modelID: Model.ID.make("@cf/model"), - package: "aisdk:@ai-sdk/openai-compatible", - settings: { baseURL: "https://proxy.example/v1" }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "cloudflare-workers-ai", - apiKey: "auth-key", - baseURL: "https://proxy.example/v1", - headers: { custom: "header" }, - }, + const catalog = yield* seed((provider) => { + provider.package = Provider.aisdk("test-provider") + provider.settings = { ...provider.settings, accountId: "configured-acct" } }) - const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk))) - expect(headers.authorization).toBe("Bearer env-key") - expect(headers.custom).toBe("header") - expect(headers["user-agent"]).toMatch(/^opencode\/.* cloudflare-workers-ai \(.+\) ai-sdk\/openai-compatible\//) - }), - ), - ) - - it.effect("expands account ID vars in endpoint URLs", () => - withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")), - modelID: Model.ID.make("@cf/model"), - package: "aisdk:@ai-sdk/openai-compatible", - settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1" }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "cloudflare-workers-ai", - baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", - }, - }) - expect(cloudflareURL(result.sdk)).toBe( - "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions", - ) - }), - ), - ) - - it.effect("selects languageModel with the API model ID", () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - const calls: string[] = [] - yield* addPlugin() - const result = yield* aisdk.runLanguage({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("alias")), - modelID: Model.ID.make("@cf/api-model"), + expect(required(yield* catalog.get(providerID))).toMatchObject({ package: "aisdk:test-provider", - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }) - expect(result.language).toBeDefined() - expect(calls).toEqual(["languageModel:@cf/api-model"]) - }), - ) - - it.effect("does not create an SDK for non OpenAI-compatible packages", () => - withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")), - modelID: Model.ID.make("@cf/model"), - package: "aisdk:@ai-sdk/anthropic", - settings: { baseURL: "https://proxy.example/v1" }, - }), - package: "@ai-sdk/anthropic", - options: { name: "cloudflare-workers-ai" }, + settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" }, }) - expect(result.sdk).toBeUndefined() }), ), ) diff --git a/packages/core/test/plugin/provider-digitalocean.test.ts b/packages/core/test/plugin/provider-digitalocean.test.ts index 332ea4c20452..c62517606996 100644 --- a/packages/core/test/plugin/provider-digitalocean.test.ts +++ b/packages/core/test/plugin/provider-digitalocean.test.ts @@ -38,7 +38,7 @@ const discovery = Effect.gen(function* () { const models = yield* Model.Service yield* providers.transform((draft) => { draft.update(providerID, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://inference.do-ai.run/v1" } }) draft.models.update(providerID, Model.ID.make("snapshot-model"), () => {}) @@ -109,7 +109,7 @@ describe("DigitalOceanPlugin", () => { expect(yield* fixture.models.get(providerID, Model.ID.make("router:alpha"))).toMatchObject({ name: "alpha", family: "digitalocean-inference-routers", - package: "aisdk:@ai-sdk/openai-compatible", + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://inference.do-ai.run/v1" }, capabilities: { tools: true, input: ["text"], output: ["text"] }, limit: { context: 128_000, output: 8_192 }, diff --git a/packages/core/test/plugin/provider-factory.test.ts b/packages/core/test/plugin/provider-factory.test.ts index bcc885befdc7..19a40a14a772 100644 --- a/packages/core/test/plugin/provider-factory.test.ts +++ b/packages/core/test/plugin/provider-factory.test.ts @@ -4,7 +4,6 @@ import { AISDK } from "@opencode/core/aisdk" import { Model } from "@opencode/core/model" import { Plugin } from "@opencode/core/plugin" import { PluginHost } from "@opencode/core/plugin/host" -import { AlibabaPlugin } from "@opencode/core/plugin/provider/alibaba" import { CoherePlugin } from "@opencode/core/plugin/provider/cohere" import { GatewayPlugin } from "@opencode/core/plugin/provider/gateway" import { PerplexityPlugin } from "@opencode/core/plugin/provider/perplexity" @@ -16,7 +15,6 @@ import { PluginTestLayer } from "./fixture" const modelID = Model.ID.make("test-model") const options = { name: "custom-provider", apiKey: "test", baseURL: "https://example.test" } const providers = [ - { id: "alibaba", plugin: AlibabaPlugin, package: "@ai-sdk/alibaba", provider: "alibaba.chat" }, { id: "cohere", plugin: CoherePlugin, package: "@ai-sdk/cohere", provider: "cohere.chat" }, { id: "gateway", plugin: GatewayPlugin, package: "@ai-sdk/gateway", provider: "gateway" }, { id: "perplexity", plugin: PerplexityPlugin, package: "@ai-sdk/perplexity", provider: "perplexity" }, diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index ece0b05bfda0..8035f442f912 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -1,5 +1,5 @@ import { AISDK } from "@opencode/core/aisdk" -import { describe, expect, mock } from "bun:test" +import { describe, expect } from "bun:test" import { Effect } from "effect" import { Model } from "@opencode/core/model" import { Plugin } from "@opencode/core/plugin" @@ -10,7 +10,6 @@ import { fakeSelectorSdk } from "../fixture/selector" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" -const googleAuthOptions: Record[] = [] const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { @@ -46,29 +45,13 @@ function withEnv(vars: Record, effect: () = ) } -void mock.module("google-auth-library", () => ({ - GoogleAuth: class { - constructor(options: Record) { - googleAuthOptions.push(options) - } - - async getClient() { - return { - async getAccessToken() { - return { token: "vertex-token" } - }, - } - } - }, -})) - describe("GoogleVertexPlugin", () => { it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () => Effect.gen(function* () { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.opencode, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { ...provider.settings, baseURL: "https://opencode.ai/zen/v1" } }), ) @@ -94,7 +77,7 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.make("google-vertex"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/google-vertex/chat" provider.settings = { ...provider.settings, baseURL: @@ -107,7 +90,7 @@ describe("GoogleVertexPlugin", () => { expect(provider.settings?.project).toBe("google-cloud-project") expect(provider.settings?.location).toBe("google-vertex-location") expect(provider).toMatchObject({ - package: "aisdk:@ai-sdk/openai-compatible", + package: "@opencode/ai/providers/google-vertex/chat", settings: { baseURL: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location", @@ -130,7 +113,7 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.make("google-vertex"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/google-vertex") + provider.package = "@opencode/ai/providers/google-vertex" }), ) yield* addPlugin() @@ -153,7 +136,7 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.make("google-vertex"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/google-vertex") + provider.package = "@opencode/ai/providers/google-vertex" }), ) yield* addPlugin() @@ -179,7 +162,7 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.make("google-vertex"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/google-vertex/chat" provider.settings = { ...provider.settings, baseURL: @@ -192,7 +175,7 @@ describe("GoogleVertexPlugin", () => { expect(provider.settings?.project).toBe("vertex-project") expect(provider).toMatchObject({ - package: "aisdk:@ai-sdk/openai-compatible", + package: "@opencode/ai/providers/google-vertex/chat", settings: { baseURL: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4", @@ -218,7 +201,7 @@ describe("GoogleVertexPlugin", () => { const models = yield* Model.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("google-vertex"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/google-vertex/chat" provider.settings = { ...provider.settings, baseURL: @@ -234,7 +217,7 @@ describe("GoogleVertexPlugin", () => { expect(provider.settings?.project).toBe("config-project") expect(provider.settings?.location).toBe("global") expect(provider).toMatchObject({ - package: "aisdk:@ai-sdk/openai-compatible", + package: "@opencode/ai/providers/google-vertex/chat", settings: { baseURL: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global" }, }) expect(model.settings).toEqual(provider.settings) @@ -247,7 +230,7 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.make("google-vertex"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/google-vertex/chat" provider.settings = { ...provider.settings, baseURL: @@ -259,12 +242,42 @@ describe("GoogleVertexPlugin", () => { yield* addPlugin() const provider = required(yield* catalog.get(Provider.ID.make("google-vertex"))) expect(provider).toMatchObject({ - package: "aisdk:@ai-sdk/openai-compatible", + package: "@opencode/ai/providers/google-vertex/chat", settings: { baseURL: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu" }, }) }), ) + it.effect("expands endpoint templates on Vertex chat models", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const models = yield* Model.Service + const modelID = Model.ID.make("meta/llama-maas") + yield* catalog.transform((catalog) => { + catalog.update(Provider.ID.googleVertex, (provider) => { + provider.package = "@opencode/ai/providers/google-vertex" + provider.settings = { project: "config-project", location: "eu" } + }) + catalog.models.update(Provider.ID.googleVertex, modelID, (model) => { + model.package = "@opencode/ai/providers/google-vertex/chat" + model.settings = { + baseURL: + "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}/endpoints/openapi", + } + }) + }) + yield* addPlugin() + expect(required(yield* models.get(Provider.ID.googleVertex, modelID))).toMatchObject({ + package: "@opencode/ai/providers/google-vertex/chat", + settings: { + project: "config-project", + location: "eu", + baseURL: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu/endpoints/openapi", + }, + }) + }), + ) + it.effect("defaults location to us-central1 when only project is configured", () => withEnv( { @@ -280,7 +293,7 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.make("google-vertex"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/google-vertex") + provider.package = "@opencode/ai/providers/google-vertex" provider.settings = { ...provider.settings, project: "config-project" } }), ) @@ -292,46 +305,6 @@ describe("GoogleVertexPlugin", () => { ), ) - it.effect("wraps an injected transport with Google auth for OpenAI-compatible Vertex endpoints", () => - Effect.gen(function* () { - googleAuthOptions.length = 0 - const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] - const plugin = yield* Plugin.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - yield* aisdk.hook.sdk((evt) => - Effect.promise(async () => { - if (evt.model.providerID !== "google-vertex") return - if (evt.package !== "@ai-sdk/openai-compatible") return - expect(typeof evt.options.fetch).toBe("function") - await evt.options.fetch("https://vertex.example", { - headers: { "x-test": "1" }, - }) - }), - ) - yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("gemini")), - modelID: Model.ID.make("gemini"), - package: "aisdk:@ai-sdk/openai-compatible", - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "google-vertex", - fetch: async (input: Parameters[0], init?: RequestInit) => { - fetchCalls.push({ input, init }) - return new Response("ok") - }, - }, - }) - const vertexCalls = fetchCalls.filter((call) => call.input === "https://vertex.example") - expect(vertexCalls).toHaveLength(1) - expect(googleAuthOptions).toEqual([{ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }]) - expect(new Headers(vertexCalls[0].init?.headers).get("authorization")).toBe("Bearer vertex-token") - expect(new Headers(vertexCalls[0].init?.headers).get("x-test")).toBe("1") - }), - ) - it.effect("trims model IDs before selecting language models", () => Effect.gen(function* () { const plugin = yield* Plugin.Service diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index 19a65285c2d0..b8298f64df85 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -26,7 +26,7 @@ describe("KiloPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("kilo"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" } provider.headers = { Existing: "value" } }) @@ -47,7 +47,7 @@ describe("KiloPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("kilo"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" } }) }) @@ -71,7 +71,7 @@ describe("KiloPlugin", () => { provider.package = Provider.aisdk("kilo") }) catalog.update(Provider.ID.make("custom-kilo"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" } }) }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index ce0bba271ac2..416d6d19e745 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -32,7 +32,7 @@ describe("LLMGatewayPlugin", () => { }) yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("llmgateway"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://api.llmgateway.io/v1" } provider.headers = { Existing: "value" } }) @@ -59,7 +59,7 @@ describe("LLMGatewayPlugin", () => { yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("llmgateway"), (provider) => { provider.activation = "disabled" - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://api.llmgateway.io/v1" } }) }) diff --git a/packages/core/test/plugin/provider-lmstudio.test.ts b/packages/core/test/plugin/provider-lmstudio.test.ts index c3047340d6c1..ec3754c00e43 100644 --- a/packages/core/test/plugin/provider-lmstudio.test.ts +++ b/packages/core/test/plugin/provider-lmstudio.test.ts @@ -304,7 +304,7 @@ describe("LMStudioPlugin", () => { yield* providers.transform((editor) => { editor.update(providerID, (provider) => { provider.name = "LMStudio" - provider.package = "aisdk:@ai-sdk/openai-compatible" + provider.package = "@opencode/ai/providers/openai-compatible" provider.integrationID = Integration.ID.make("lmstudio") }) editor.models.update(providerID, Model.ID.make("static-model"), () => {}) diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index 74a9d4c47d32..af8e6c5ea343 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -26,7 +26,7 @@ describe("NvidiaPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("nvidia"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" } provider.headers = { Existing: "value" } }) @@ -48,7 +48,7 @@ describe("NvidiaPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("nvidia"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" } }) }) @@ -67,7 +67,7 @@ describe("NvidiaPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("nvidia"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" } provider.headers = { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" } }) diff --git a/packages/core/test/plugin/provider-ollama.test.ts b/packages/core/test/plugin/provider-ollama.test.ts index 3513a9a6d91a..4c445c5b51a6 100644 --- a/packages/core/test/plugin/provider-ollama.test.ts +++ b/packages/core/test/plugin/provider-ollama.test.ts @@ -201,7 +201,7 @@ describe("OllamaPlugin", () => { yield* providers.transform((editor) => { editor.update(providerID, (provider) => { provider.name = "Ollama" - provider.package = "aisdk:@ai-sdk/openai-compatible" + provider.package = "@opencode/ai/providers/openai-compatible" provider.integrationID = Integration.ID.make("ollama") }) editor.models.update(providerID, Model.ID.make("static-model"), () => {}) diff --git a/packages/core/test/plugin/provider-openai-compatible.test.ts b/packages/core/test/plugin/provider-openai-compatible.test.ts deleted file mode 100644 index f9a4b815c31b..000000000000 --- a/packages/core/test/plugin/provider-openai-compatible.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { AISDK } from "@opencode/core/aisdk" -import { describe, expect } from "bun:test" -import { Effect } from "effect" -import { Model } from "@opencode/core/model" -import { Plugin } from "@opencode/core/plugin" -import { PluginHost } from "@opencode/core/plugin/host" -import { OpenAICompatiblePlugin } from "@opencode/core/plugin/provider/openai-compatible" -import { Provider } from "@opencode/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* Plugin.Service - const host = yield* PluginHost.make(plugin) - yield* OpenAICompatiblePlugin.effect(host) -}) - -describe("OpenAICompatiblePlugin", () => { - it.effect("preserves explicit includeUsage false and defaults it to true", () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - yield* addPlugin() - const defaulted = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("model")), - modelID: Model.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "custom" }, - }) - const disabled = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("model")), - modelID: Model.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "custom", includeUsage: false }, - }) - expect(defaulted.options.includeUsage).toBe(true) - expect(disabled.options.includeUsage).toBe(false) - }), - ) - - it.effect("defaults includeUsage for OpenAI-compatible package matches", () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("model")), - modelID: Model.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", - options: { name: "custom" }, - }) - expect(result.options.includeUsage).toBe(true) - }), - ) - - it.effect("uses the provider ID as the OpenAI-compatible provider name", () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - const observed: string[] = [] - yield* addPlugin() - yield* aisdk.hook.sdk((event) => - Effect.sync(() => { - observed.push(event.sdk.languageModel("model").provider) - }), - ) - yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("custom-provider"), Model.ID.make("model")), - modelID: Model.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "custom-provider", baseURL: "https://example.com/v1" }, - }) - expect(observed).toEqual(["custom-provider.chat"]) - }), - ) - - it.effect("does not overwrite an SDK created by an earlier provider-specific plugin", () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - const sentinel = { languageModel: (modelID: string) => ({ modelID }) } - yield* aisdk.hook.sdk((event) => { - event.sdk = sentinel - }) - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("model")), - modelID: Model.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai" }, - }) - expect(result.sdk).toBe(sentinel) - }), - ) -}) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 34b5b4a7cc13..843ebc712809 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -87,7 +87,7 @@ describe("OpenAIPlugin", () => { const credentials = yield* Credential.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.openai, (draft) => { - draft.package = Provider.aisdk("@ai-sdk/openai") + draft.package = "@opencode/ai/providers/openai" }) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), (model) => { model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } @@ -143,7 +143,7 @@ describe("OpenAIPlugin", () => { const proxy = yield* request(Provider.ID.openai, "https://proxy.example/v1?region=us") const provider = required(yield* catalog.get(Provider.ID.openai)) - expect(provider.package).toBe(Provider.aisdk("@ai-sdk/openai")) + expect(provider.package).toBe("@opencode/ai/providers/openai") expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" }) expect(provider.headers).toMatchObject({ originator: "opencode", @@ -157,7 +157,7 @@ describe("OpenAIPlugin", () => { expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us") expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) const eligible = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) - expect(eligible.package).toBe(Provider.aisdk("@ai-sdk/openai")) + expect(eligible.package).toBe("@opencode/ai/providers/openai") expect(eligible.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" }) expect(eligible.cost).toEqual([]) expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) @@ -173,9 +173,7 @@ describe("OpenAIPlugin", () => { expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-6-astra"))).enabled).toBe(true) expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.10"))).enabled).toBe(true) expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(false) - expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe( - false, - ) + expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe(false) expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-4.99"))).enabled).toBe(false) }), ) @@ -187,7 +185,7 @@ describe("OpenAIPlugin", () => { const credentials = yield* Credential.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.openai, (draft) => { - draft.package = Provider.aisdk("@ai-sdk/openai") + draft.package = "@opencode/ai/providers/openai" }) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), (model) => { model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } @@ -204,7 +202,7 @@ describe("OpenAIPlugin", () => { const provider = required(yield* catalog.get(Provider.ID.openai)) const model = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) - expect(model.package).toBe(Provider.aisdk("@ai-sdk/openai")) + expect(model.package).toBe("@opencode/ai/providers/openai") expect(model.enabled).toBe(true) expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) expect(model.capabilities.responsesWebsockets).toBe(true) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 53e6cb45fd0d..202207358926 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -432,11 +432,11 @@ describe("OpencodePlugin", () => { const integrations = yield* Integration.Service yield* providers.transform((editor) => { editor.update(Provider.ID.openai, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai") + provider.package = "@opencode/ai/providers/openai" provider.integrationID = Integration.ID.make("openai") }) editor.models.update(Provider.ID.openai, Model.ID.make("api-model"), (model) => { - model.package = Provider.aisdk("@ai-sdk/openai") + model.package = "@opencode/ai/providers/openai" model.settings = { baseURL: "https://upstream.example/v1" } model.variants = [ { @@ -468,7 +468,7 @@ describe("OpencodePlugin", () => { canonical: "openai", name: "Remote", integrationID: "opencode", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: `${server.url.origin}/v1`, custom: "value" }, headers: { "x-org-id": "org" }, }) @@ -485,13 +485,18 @@ describe("OpencodePlugin", () => { capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }], limit: { context: 1000, output: 100 }, - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 }, headers: { "x-org-id": "org" }, }) - expect(model.settings).toEqual({ baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 }) + expect(model.settings).toEqual({ + baseURL: `${server.url.origin}/v1`, + custom: "value", + temperature: 0.5, + provider: "openai", + }) const override = required(yield* models.get(Provider.ID.make("remote"), Model.ID.make("override"))) - expect(override.package).toBe(Provider.aisdk("@ai-sdk/anthropic")) + expect(override.package).toBe("@opencode/ai/providers/anthropic") expect(override.settings?.baseURL).toBe(`${server.url.origin}/anthropic`) expect(model.variants).toEqual([ { @@ -506,9 +511,7 @@ describe("OpencodePlugin", () => { headers: { "x-variant": "high" }, }, ]) - expect( - required(yield* models.get(Provider.ID.make("remote"), Model.ID.make("disabled"))).enabled, - ).toBe(false) + expect(required(yield* models.get(Provider.ID.make("remote"), Model.ID.make("disabled"))).enabled).toBe(false) expect(yield* models.get(Provider.ID.make("remote"), Model.ID.make("stale"))).toBeDefined() expect( (yield* providers.snapshot()).records.get(Provider.ID.openai)?.models.get(Model.ID.make("api-model")) @@ -1120,9 +1123,7 @@ describe("OpencodePlugin", () => { }) yield* addPlugin() expect(required(yield* catalog.get(Provider.ID.opencode)).settings?.apiKey).toBe("public") - expect(required(yield* models.get(Provider.ID.opencode, Model.ID.make("output-only"))).enabled).toBe( - true, - ) + expect(required(yield* models.get(Provider.ID.opencode, Model.ID.make("output-only"))).enabled).toBe(true) }), ), ) diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index de5a4fc40c05..0e414506c222 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -27,7 +27,7 @@ describe("OpenRouterPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.openrouter, (provider) => { - provider.package = Provider.aisdk("@openrouter/ai-sdk-provider") + provider.package = "@opencode/ai/providers/openrouter" provider.headers = { Existing: "value" } }) catalog.update(Provider.ID.make("nvidia"), () => {}) @@ -49,7 +49,7 @@ describe("OpenRouterPlugin", () => { const models = yield* Model.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.openrouter, (provider) => { - provider.package = Provider.aisdk("@openrouter/ai-sdk-provider") + provider.package = "@opencode/ai/providers/openrouter" }) catalog.update(Provider.ID.openai, () => {}) catalog.models.update(Provider.ID.openrouter, Model.ID.make("openai/gpt-5-chat"), () => {}) diff --git a/packages/core/test/plugin/provider-poe.test.ts b/packages/core/test/plugin/provider-poe.test.ts index 707e9d348b0c..f7aaebc97ccd 100644 --- a/packages/core/test/plugin/provider-poe.test.ts +++ b/packages/core/test/plugin/provider-poe.test.ts @@ -42,7 +42,7 @@ const fixture = Effect.gen(function* () { }) yield* providers.transform((editor) => { editor.update(providerID, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://api.poe.com/v1" } }) editor.models.update(providerID, modelID, () => {}) diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index 44861ed034d3..5651dcac311b 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -56,7 +56,7 @@ const fixture = Effect.fn(function* () { const host = yield* PluginHost.make(plugin) yield* providers.transform((editor) => { editor.update(providerID, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://${SNOWFLAKE_ACCOUNT}.snowflakecomputing.com/api/v2/cortex/v1" } }) editor.models.update(providerID, modelID, () => {}) @@ -226,7 +226,7 @@ it.live("uses environment tokens rather than the account identifier and bypasses name: "SNOWFLAKE_CORTEX_TOKEN", }) expect((yield* test.providers.get(providerID))?.package).toBe( - Provider.aisdk("@ai-sdk/openai-compatible"), + "@opencode/ai/providers/openai-compatible", ) expect(yield* test.hooks.has("aisdk", "sdk", providerID)).toBe(false) diff --git a/packages/core/test/plugin/provider-vllm.test.ts b/packages/core/test/plugin/provider-vllm.test.ts index efc36e1cc1f2..744f1ca2e478 100644 --- a/packages/core/test/plugin/provider-vllm.test.ts +++ b/packages/core/test/plugin/provider-vllm.test.ts @@ -185,7 +185,7 @@ describe("VLLMPlugin", () => { yield* providers.transform((editor) => { editor.update(providerID, (provider) => { provider.name = "vLLM" - provider.package = "aisdk:@ai-sdk/openai-compatible" + provider.package = "@opencode/ai/providers/openai-compatible" provider.integrationID = Integration.ID.make("vllm") provider.activation = "auto" }) diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index cebf008270c2..f269c33561db 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -75,7 +75,7 @@ describe("XAIPlugin", () => { const providerID = Provider.ID.make("xai") yield* providers.transform((editor) => { editor.update(providerID, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/xai") + provider.package = "@opencode/ai/providers/xai" provider.activation = "enabled" }) editor.models.update(providerID, Model.ID.make("grok-4.6"), () => {}) diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 221fd030fad4..a13e8b507d2b 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -31,7 +31,7 @@ describe("ZenmuxPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("zenmux"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" } }) }) @@ -47,7 +47,7 @@ describe("ZenmuxPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("zenmux"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" } provider.headers = { ...provider.headers, Existing: "value" } }) @@ -67,7 +67,7 @@ describe("ZenmuxPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("zenmux"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" } provider.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } }) diff --git a/packages/core/test/plugin/variant.test.ts b/packages/core/test/plugin/variant.test.ts deleted file mode 100644 index 59b3257c3bbe..000000000000 --- a/packages/core/test/plugin/variant.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect } from "bun:test" -import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" -import { LayerNode } from "@opencode/util/effect/layer-node" -import { Location } from "@opencode/core/location" -import { Model } from "@opencode/core/model" -import { VariantPlugin } from "@opencode/core/plugin/variant" -import { Provider } from "@opencode/core/provider" -import { AbsolutePath } from "@opencode/core/schema" -import { Effect, Layer } from "effect" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" -import { modelHost, host } from "./host" - -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), -) -const it = testEffect( - AppNodeBuilder.build(LayerNode.group([Provider.node, Model.node]), [Location.node.replace(locationLayer)]), -) - -describe("VariantPlugin", () => { - it.effect("adds GLM 5.2 variants after catalog sources", () => - Effect.gen(function* () { - const providers = yield* Provider.Service - const models = yield* Model.Service - yield* providers.transform((editor) => { - editor.update(Provider.ID.opencode, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") - }) - editor.models.update(Provider.ID.opencode, Model.ID.make("glm-5.2"), (model) => { - model.modelID = Model.ID.make("glm-5.2") - model.package = Provider.aisdk("@ai-sdk/openai-compatible") - }) - }) - yield* VariantPlugin.Plugin.effect(host({ model: modelHost(models) })) - - expect((yield* models.get(Provider.ID.opencode, Model.ID.make("glm-5.2")))?.variants).toEqual([ - expect.objectContaining({ id: "high", settings: { reasoningEffort: "high" } }), - expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }), - ]) - }), - ) - - it.effect("keeps explicit variants over generated defaults", () => - Effect.gen(function* () { - const providers = yield* Provider.Service - const models = yield* Model.Service - yield* providers.transform((editor) => { - editor.models.update(Provider.ID.opencode, Model.ID.make("glm-5.2"), (model) => { - model.modelID = Model.ID.make("glm-5.2") - model.package = Provider.aisdk("@ai-sdk/openai-compatible") - model.variants = [{ id: Model.VariantID.make("high"), settings: {}, headers: { custom: "true" }, body: {} }] - }) - }) - yield* VariantPlugin.Plugin.effect(host({ model: modelHost(models) })) - - expect((yield* models.get(Provider.ID.opencode, Model.ID.make("glm-5.2")))?.variants).toEqual([ - expect.objectContaining({ id: "high", headers: { custom: "true" } }), - expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }), - ]) - }), - ) -}) diff --git a/packages/core/test/provider-mistral.test.ts b/packages/core/test/provider-mistral.test.ts deleted file mode 100644 index 6e3176695f67..000000000000 --- a/packages/core/test/provider-mistral.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { createMistral } from "@ai-sdk/mistral" -import { expect, test } from "bun:test" - -test("Mistral sends promptCacheKey as prompt_cache_key", async () => { - let body: Record | undefined - const mockFetch = Object.assign( - async (_input: Parameters[0], init?: RequestInit) => { - body = JSON.parse(String(init?.body)) - return Response.json({ - id: "response-1", - created: 0, - model: "mistral-large-latest", - object: "chat.completion", - choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }) - }, - { preconnect: fetch.preconnect }, - ) - const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-large-latest") - - await model.doGenerate({ - prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], - providerOptions: { mistral: { promptCacheKey: "session-123" } }, - }) - - expect(body?.prompt_cache_key).toBe("session-123") -}) - -test("Mistral round-trips native reasoning in assistant history", async () => { - let body: { messages?: unknown[] } | undefined - const mockFetch = Object.assign( - async (_input: Parameters[0], init?: RequestInit) => { - body = JSON.parse(String(init?.body)) - return Response.json({ - id: "response-1", - created: 0, - model: "mistral-small-latest", - object: "chat.completion", - choices: [ - { - index: 0, - message: { - role: "assistant", - content: [ - { - type: "thinking", - thinking: [ - { type: "text", text: "The user is greeting me." }, - { - type: "tool_reference", - tool: "web_search", - title: "Example result", - url: "https://example.com/tool", - favicon: "https://example.com/favicon.ico", - description: "Example description", - }, - { type: "reference", reference_ids: [1, "source-2"] }, - ], - closed: true, - signature: "sig-123", - }, - { type: "text", text: "Hi" }, - ], - }, - finish_reason: "stop", - }, - ], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }) - }, - { preconnect: fetch.preconnect }, - ) - const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") - - const first = await model.doGenerate({ - prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], - }) - const reasoning = first.content.find((part) => part.type === "reasoning") - const text = first.content.find((part) => part.type === "text") - if (!reasoning || !text) throw new Error("expected reasoning and text") - - await model.doGenerate({ - prompt: [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - { - role: "assistant", - content: [{ ...reasoning, providerOptions: reasoning.providerMetadata }, text], - }, - { role: "user", content: [{ type: "text", text: "Hello again" }] }, - ], - }) - - expect(body?.messages?.[1]).toEqual({ - role: "assistant", - content: [ - { - type: "thinking", - thinking: [ - { type: "text", text: "The user is greeting me." }, - { - type: "tool_reference", - tool: "web_search", - title: "Example result", - url: "https://example.com/tool", - favicon: "https://example.com/favicon.ico", - description: "Example description", - }, - { type: "reference", reference_ids: [1, "source-2"] }, - ], - closed: true, - signature: "sig-123", - }, - { type: "text", text: "Hi" }, - ], - }) - - await model.doGenerate({ - prompt: [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - { - role: "assistant", - content: [ - { type: "reasoning", text: "thinking" }, - { type: "text", text: "Hi" }, - ], - }, - { role: "user", content: [{ type: "text", text: "Hello again" }] }, - ], - }) - expect(body?.messages?.[1]).toEqual({ role: "assistant", content: "thinkingHi" }) -}) - -test("Mistral preserves native reasoning metadata while streaming", async () => { - const chunks = [ - { - id: "response-1", - created: 0, - model: "mistral-small-latest", - choices: [ - { - index: 0, - delta: { - role: "assistant", - content: [ - { - type: "thinking", - thinking: [ - { type: "text", text: "thinking" }, - { - type: "tool_reference", - tool: "web_search", - title: "Example result", - url: "https://example.com/tool", - favicon: "https://example.com/favicon.ico", - description: "Example description", - }, - ], - }, - ], - }, - }, - ], - }, - { - id: "response-1", - created: 0, - model: "mistral-small-latest", - choices: [ - { - index: 0, - delta: { - content: [ - { - type: "thinking", - thinking: [{ type: "reference", reference_ids: [1, "source-2"] }], - closed: true, - signature: "sig-123", - }, - ], - }, - }, - ], - }, - { - id: "response-1", - created: 0, - model: "mistral-small-latest", - choices: [{ index: 0, delta: { content: [{ type: "text", text: "answer" }] } }], - }, - { - id: "response-1", - created: 0, - model: "mistral-small-latest", - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ] - const mockFetch = Object.assign( - async () => - new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), { - headers: { "Content-Type": "text/event-stream" }, - }), - { preconnect: fetch.preconnect }, - ) - const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") - const result = await model.doStream({ - prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], - }) - const events = [] - for await (const event of result.stream) events.push(event) - - expect(events.find((event) => event.type === "reasoning-end")?.providerMetadata).toEqual({ - mistral: { - thinking: { - type: "thinking", - thinking: [ - { type: "text", text: "thinking" }, - { - type: "tool_reference", - tool: "web_search", - title: "Example result", - url: "https://example.com/tool", - favicon: "https://example.com/favicon.ico", - description: "Example description", - }, - { type: "reference", reference_ids: [1, "source-2"] }, - ], - closed: true, - signature: "sig-123", - }, - }, - }) - expect( - events - .filter((event) => event.type === "reasoning-start" || event.type === "reasoning-delta") - .every((event) => event.providerMetadata === undefined), - ).toBe(true) -}) - -test("Mistral preserves metadata-only thinking chunks", async () => { - const thinking = { - type: "thinking" as const, - thinking: [ - { - type: "tool_reference", - tool: "web_search", - title: "Example result", - url: "https://example.com/tool", - favicon: "https://example.com/favicon.ico", - description: "Example description", - }, - { type: "reference", reference_ids: [1, "source-2"] }, - ], - closed: true, - signature: "sig-123", - } - const mockFetch = Object.assign( - async () => - Response.json({ - id: "response-1", - created: 0, - model: "mistral-small-latest", - object: "chat.completion", - choices: [{ index: 0, message: { role: "assistant", content: [thinking] }, finish_reason: "stop" }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }), - { preconnect: fetch.preconnect }, - ) - const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") - const result = await model.doGenerate({ - prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], - }) - - expect(result.content).toEqual([ - { - type: "reasoning", - text: "", - providerMetadata: { mistral: { thinking } }, - }, - ]) -}) diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 858e8f485994..2c280ba6d04a 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -144,7 +144,7 @@ describe("toSessionError", () => { const configuration = new ModelResolver.ModelConfigurationError({ providerID: Provider.ID.make("azure"), modelID: ID.make("gpt-5.4-nano"), - package: "aisdk:@ai-sdk/azure", + package: "@opencode/ai/providers/azure/responses", detail: "Azure requires resourceName or baseURL", }) expect(toSessionError(configuration)).toEqual({ diff --git a/packages/core/test/variant.test.ts b/packages/core/test/variant.test.ts new file mode 100644 index 000000000000..f4fe560ddce0 --- /dev/null +++ b/packages/core/test/variant.test.ts @@ -0,0 +1,269 @@ +import { expect, test } from "bun:test" +import { Model } from "@opencode/core/model" +import { Provider } from "@opencode/core/provider" +import { Variant } from "@opencode/core/variant" + +const model = (packageName: string, modelID: string, output?: number, providerID = "test") => { + const result = Model.Info.default(Provider.ID.make(providerID), Model.ID.make(modelID)) as Model.MutableInfo + result.package = packageName + result.modelID = Model.ID.make(modelID) + if (output !== undefined) result.limit = { ...result.limit, output } + return Model.Info.make(result) +} + +const resolve = (input: Model.Info, supports: readonly Variant.Support[]) => + Variant.resolve(input, supports).map((item) => ({ ...item, id: String(item.id) })) + +test("spells Messages variants for each provider", () => { + expect( + resolve(model("@opencode/ai/providers/anthropic", "claude-opus-4-5"), [ + { type: "effort", values: ["low", "high"] }, + { type: "budget_tokens", min: 1024 }, + ]), + ).toEqual([ + { + id: "low", + settings: { effort: "low", thinking: { type: "enabled", budgetTokens: 16_000 } }, + }, + { + id: "high", + settings: { effort: "high", thinking: { type: "enabled", budgetTokens: 16_000 } }, + }, + ]) + + expect( + resolve(model("@opencode/ai/providers/anthropic", "claude-opus-4-8"), [{ type: "effort", values: ["low", "max"] }]), + ).toEqual([ + { id: "low", settings: { effort: "low", thinking: { type: "adaptive", display: "summarized" } } }, + { id: "max", settings: { effort: "max", thinking: { type: "adaptive", display: "summarized" } } }, + ]) + + expect(resolve(model("@opencode/ai/providers/minimax/messages", "MiniMax-M3"), [{ type: "toggle" }])).toEqual([ + { id: "none", settings: { thinking: { type: "disabled" } } }, + { id: "thinking", settings: { thinking: { type: "adaptive" } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/moonshot/messages", "k3"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + ]), + ).toEqual([ + { id: "low", settings: { effort: "low" } }, + { id: "high", settings: { effort: "high" } }, + { id: "max", settings: { effort: "max" } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/alibaba/messages", "qwen3.8-max"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "medium", "xhigh"] }, + { type: "budget_tokens", min: 1024, max: 262_144 }, + ]), + ).toEqual([ + { id: "none", settings: { thinking: { type: "disabled" } } }, + { id: "low", settings: { effort: "low", thinking: { type: "enabled" } } }, + { id: "medium", settings: { effort: "medium", thinking: { type: "enabled" } } }, + { id: "xhigh", settings: { effort: "xhigh", thinking: { type: "enabled" } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/zai-coding-plan/messages", "glm-5.3"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + { type: "budget_tokens", min: 1024 }, + ]), + ).toEqual([ + { id: "low", settings: { effort: "low", thinking: { type: "enabled" } } }, + { id: "high", settings: { effort: "high", thinking: { type: "enabled" } } }, + { id: "max", settings: { effort: "max", thinking: { type: "enabled" } } }, + ]) +}) + +test("recognizes Claude version spellings and future models", () => { + for (const id of ["claude-sonnet-3.7", "claude-sonnet-3-7", "claude-3.7-sonnet", "claude-3-7-sonnet"]) + expect(resolve(model("@opencode/ai/providers/anthropic", id), [{ type: "effort" }])).toEqual([ + { id: "high", settings: { thinking: { type: "enabled", budgetTokens: 16000 } } }, + { id: "max", settings: { thinking: { type: "enabled", budgetTokens: 31999 } } }, + ]) + + expect(resolve(model("@opencode/ai/providers/anthropic", "claude-opus-6"), [{ type: "effort" }])).toEqual( + ["low", "medium", "high", "xhigh", "max"].map((effort) => ({ + id: effort, + settings: { effort, thinking: { type: "adaptive", display: "summarized" } }, + })), + ) +}) + +test("spells Cloudflare AI Gateway variants for their upstream routes", () => { + const pkg = "@opencode/ai/providers/cloudflare-ai-gateway" + expect(resolve(model(pkg, "openai/gpt-5.4"), [{ type: "effort", values: ["low", "xhigh"] }])).toEqual([ + { + id: "low", + settings: { reasoningEffort: "low", reasoningSummary: "auto", include: ["reasoning.encrypted_content"] }, + }, + { + id: "xhigh", + settings: { reasoningEffort: "xhigh", reasoningSummary: "auto", include: ["reasoning.encrypted_content"] }, + }, + ]) + expect(resolve(model(pkg, "anthropic/claude-sonnet-4-6"), [{ type: "effort", values: ["low", "high"] }])).toEqual([ + { id: "low", settings: { effort: "low", thinking: { type: "adaptive", display: "summarized" } } }, + { id: "high", settings: { effort: "high", thinking: { type: "adaptive", display: "summarized" } } }, + ]) + expect(resolve(model(pkg, "xai/grok-4.6"), [{ type: "effort", values: ["low", "high"] }])).toEqual([ + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + ]) +}) + +test("spells Chat Completions variants for direct providers", () => { + expect( + resolve(model("@opencode/ai/providers/deepseek", "deepseek-v4-flash"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + ]), + ).toEqual([ + { id: "none", body: { thinking: { type: "disabled" } } }, + { id: "low", settings: { reasoningEffort: "low" }, body: { thinking: { type: "enabled" } } }, + { id: "high", settings: { reasoningEffort: "high" }, body: { thinking: { type: "enabled" } } }, + { id: "max", settings: { reasoningEffort: "max" }, body: { thinking: { type: "enabled" } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/moonshot/chat", "kimi-k3"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + ]), + ).toEqual([ + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + { id: "max", settings: { reasoningEffort: "max" } }, + ]) + + expect(resolve(model("@opencode/ai/providers/moonshot/chat", "kimi-k2.6"), [{ type: "toggle" }])).toEqual([ + { id: "none", settings: { thinking: { type: "disabled" } } }, + { id: "thinking", settings: { thinking: { type: "enabled" } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/alibaba/chat", "qwen3.8-max"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "medium", "xhigh"] }, + { type: "budget_tokens", min: 0, max: 262_144 }, + ]), + ).toEqual([ + { id: "none", settings: { enableThinking: false } }, + { id: "low", settings: { enableThinking: true, reasoningEffort: "low" } }, + { id: "medium", settings: { enableThinking: true, reasoningEffort: "medium" } }, + { id: "xhigh", settings: { enableThinking: true, reasoningEffort: "xhigh" } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/alibaba/chat", "qwen3.7-plus", 300_000), [ + { type: "toggle" }, + { type: "budget_tokens", min: 0, max: 262_144 }, + ]), + ).toEqual([ + { id: "none", settings: { enableThinking: false } }, + { id: "high", settings: { enableThinking: true, thinkingBudget: 131_072 } }, + { id: "max", settings: { enableThinking: true, thinkingBudget: 262_144 } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/zai/chat", "glm-5.3"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + ]), + ).toEqual([ + { id: "low", settings: { thinking: { type: "enabled", clear_thinking: false }, reasoningEffort: "low" } }, + { id: "high", settings: { thinking: { type: "enabled", clear_thinking: false }, reasoningEffort: "high" } }, + { id: "max", settings: { thinking: { type: "enabled", clear_thinking: false }, reasoningEffort: "max" } }, + ]) + + expect(resolve(model("@opencode/ai/providers/zai/chat", "glm-4.7"), [{ type: "toggle" }])).toEqual([ + { id: "none", settings: { thinking: { type: "disabled" } } }, + { id: "thinking", settings: { thinking: { type: "enabled", clear_thinking: false } } }, + ]) +}) + +test("spells Chat Completions variants for hosting providers", () => { + expect( + resolve(model("@opencode/ai/providers/openai-compatible", "deepseek-ai/deepseek-v4-pro", undefined, "nvidia"), [ + { type: "effort", values: ["none", "high", "max"] }, + ]), + ).toEqual([ + { id: "none", body: { chat_template_kwargs: { thinking: false } } }, + { id: "high", body: { chat_template_kwargs: { thinking: true, reasoning_effort: "high" } } }, + { id: "max", body: { chat_template_kwargs: { thinking: true, reasoning_effort: "max" } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/openai-compatible", "moonshotai/kimi-k2.6", undefined, "nvidia"), [ + { type: "effort", values: ["none", "low", "high", "max"] }, + ]), + ).toEqual([ + { id: "none", body: { chat_template_kwargs: { thinking: false } } }, + { id: "thinking", body: { chat_template_kwargs: { thinking: true } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/openai-compatible", "moonshotai/kimi-k3", undefined, "nvidia"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + ]), + ).toEqual([ + { id: "none", body: { chat_template_kwargs: { thinking: false } } }, + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + { id: "max", settings: { reasoningEffort: "max" } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/openai-compatible", "minimaxai/minimax-m3", undefined, "nvidia"), [ + { type: "toggle" }, + ]), + ).toEqual([ + { id: "none", body: { chat_template_kwargs: { thinking_mode: "disabled" } } }, + { id: "thinking", body: { chat_template_kwargs: { thinking_mode: "enabled" } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/baseten", "zai-org/GLM-5.2"), [ + { type: "effort", values: ["none", "high", "max"] }, + ]), + ).toEqual([ + { + id: "none", + settings: { reasoningEffort: "none" }, + body: { chat_template_args: { enable_thinking: false } }, + }, + { + id: "high", + settings: { reasoningEffort: "high" }, + body: { chat_template_args: { enable_thinking: true } }, + }, + { + id: "max", + settings: { reasoningEffort: "max" }, + body: { chat_template_args: { enable_thinking: true } }, + }, + ]) + + expect(resolve(model("@opencode/ai/providers/baseten", "moonshotai/Kimi-K2.6"), [{ type: "toggle" }])).toEqual([ + { id: "none", body: { chat_template_args: { enable_thinking: false } } }, + { id: "thinking", body: { chat_template_args: { enable_thinking: true } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/deepinfra", "zai-org/GLM-5.2"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "xhigh"] }, + ]), + ).toEqual([ + { id: "none", body: { reasoning: { enabled: false } } }, + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + { id: "xhigh", settings: { reasoningEffort: "xhigh" } }, + ]) +})