mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-25 10:00:48 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77f7642d09 | |||
| 081eef3b80 | |||
| 98f2a87304 | |||
| 23402629c5 | |||
| 90ac6f9724 | |||
| f02b21a69a | |||
| 7dcafbfa0b | |||
| 2463612418 | |||
| 2226afaea5 |
@@ -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=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-MLA+R+7GovdBAVtJQ1pCOtXt16s6uC7Aplfjy5k3Q2M=",
|
||||
"aarch64-linux": "sha256-mCfBpIpdeTDBSvUaKU7zGCAeV5UfkEaf3dYl5Y3okvg=",
|
||||
"aarch64-darwin": "sha256-VmDVOpRFfR5f9GlboLJWHREcG8ClpClHU27p7tb9BkI=",
|
||||
"x86_64-darwin": "sha256-wKw5EPQzuc9IptLLgJnBi6XWr3qN8Ze3GCElVFXLs6g="
|
||||
"x86_64-linux": "sha256-E5T4o3wNivOg8q4wRV7yE4CUNaUq4QNPxZsnvLaGUcg=",
|
||||
"aarch64-linux": "sha256-xQQi7LgxInZQVCznASfa0Pm+cNGBo5j4Tfp0E/bKnNw=",
|
||||
"aarch64-darwin": "sha256-qxb371dCf7WG09VvQE+HZ/x3EURpFIr11bdnQwGTHhw=",
|
||||
"x86_64-darwin": "sha256-xi1qiYr41hzbgAQl+I3xdtnkiPx2SIcA/0OMpN78qXw="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
|
||||
readonly gatewayApiKey?: string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
|
||||
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<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
model: (input: string | ModelID) => {
|
||||
const wire = modelID(input)
|
||||
if (String(input).startsWith("openai/")) return responses.model<OpenAIProviderOptionsInput>({ id: wire })
|
||||
if (String(input).startsWith("anthropic/")) return messages.model<OpenAIProviderOptionsInput>({ id: wire })
|
||||
return chat.model<OpenAIProviderOptionsInput>({ id: wire })
|
||||
},
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = { id, configure }
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
const {
|
||||
accountId: _,
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-4
@@ -4,9 +4,9 @@
|
||||
"name": "cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call",
|
||||
"recordedAt": "2026-05-08T17:20:08.287Z",
|
||||
"provider": "cloudflare-ai-gateway",
|
||||
"route": "cloudflare-ai-gateway",
|
||||
"route": "cloudflare-ai-gateway-chat",
|
||||
"transport": "http",
|
||||
"model": "workers-ai/@cf/openai/gpt-oss-20b",
|
||||
"model": "@cf/openai/gpt-oss-20b",
|
||||
"tags": ["prefix:cloudflare-ai-gateway", "provider:cloudflare-ai-gateway", "tool", "tool-call", "golden"]
|
||||
},
|
||||
"interactions": [
|
||||
@@ -14,11 +14,11 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/compat/chat/completions",
|
||||
"url": "https://api.cloudflare.com/client/v4/accounts/{account}/ai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"workers-ai/@cf/openai/gpt-oss-20b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}"
|
||||
"body": "{\"model\":\"@cf/openai/gpt-oss-20b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
+4
-4
@@ -4,9 +4,9 @@
|
||||
"name": "cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text",
|
||||
"recordedAt": "2026-05-08T15:55:48.952Z",
|
||||
"provider": "cloudflare-ai-gateway",
|
||||
"route": "cloudflare-ai-gateway",
|
||||
"route": "cloudflare-ai-gateway-chat",
|
||||
"transport": "http",
|
||||
"model": "workers-ai/@cf/meta/llama-3.1-8b-instruct",
|
||||
"model": "@cf/meta/llama-3.1-8b-instruct",
|
||||
"tags": ["prefix:cloudflare-ai-gateway", "provider:cloudflare-ai-gateway", "text", "golden"]
|
||||
},
|
||||
"interactions": [
|
||||
@@ -14,11 +14,11 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/compat/chat/completions",
|
||||
"url": "https://api.cloudflare.com/client/v4/accounts/{account}/ai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"workers-ai/@cf/meta/llama-3.1-8b-instruct\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}"
|
||||
"body": "{\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -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 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." }),
|
||||
)
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, 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" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -72,7 +72,8 @@ test("renames, exports, and deletes a home session from its context menu", async
|
||||
expect(await container.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe("none")
|
||||
await title.fill("Renamed from Home")
|
||||
const renamed = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname.endsWith("/rename"),
|
||||
(request) =>
|
||||
request.method() === "PATCH" && new URL(request.url()).pathname.endsWith(`/session/${fixture.targetID}`),
|
||||
)
|
||||
await title.press("Enter")
|
||||
expect((await renamed).postDataJSON()).toEqual({ title: "Renamed from Home" })
|
||||
|
||||
@@ -491,7 +491,8 @@ async function openDraft(
|
||||
if (request.method() !== "POST") return
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path === "/api/worktree") {
|
||||
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
expect(new URL(request.url()).searchParams.has("location[directory]")).toBe(false)
|
||||
expect(request.postDataJSON()).toMatchObject({ projectID })
|
||||
calls.push("worktree")
|
||||
worktreeRequests.push(request.postDataJSON())
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
|
||||
const directory = "C:/Projects/settings-demo"
|
||||
const sandboxes = Array.from({ length: 12 }, (_, index) => `${directory}/workspace-${index + 1}`)
|
||||
const events: OpenCodeEvent[] = []
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
events.length = 0
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -40,6 +43,7 @@ test.beforeEach(async ({ page }) => {
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
})),
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
@@ -52,6 +56,31 @@ test.beforeEach(async ({ page }) => {
|
||||
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
|
||||
})
|
||||
|
||||
test("an inventory event updates the open all-project worktree list", async ({ page }) => {
|
||||
const discovered = `${directory}/discovered-workspace`
|
||||
const inventory = [{ directory }, ...sandboxes.map((directory) => ({ directory, strategy: "git" }))]
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
(route) => route.fulfill({ json: inventory }),
|
||||
)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
|
||||
const listed = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === "/api/worktree" && response.request().method() === "GET",
|
||||
)
|
||||
inventory.push({ directory: discovered, strategy: "git" })
|
||||
events.push({
|
||||
id: "evt_settings_worktree_updated",
|
||||
created: Date.now(),
|
||||
type: "worktree.updated",
|
||||
data: { projectID: "proj_settings_demo" },
|
||||
})
|
||||
expect((await listed).ok()).toBe(true)
|
||||
await expect(settings.getByText(discovered, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("settings has its own route and returns through app history", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const home = page.getByRole("button", { name: "Home", exact: true })
|
||||
@@ -223,7 +252,7 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
|
||||
const requested = page.waitForRequest(
|
||||
(request) =>
|
||||
new URL(request.url()).pathname === "/api/worktree" &&
|
||||
new URL(request.url()).searchParams.get("location[directory]") === directory &&
|
||||
new URL(request.url()).searchParams.get("projectID") === "proj_settings_demo" &&
|
||||
request.method() === "GET",
|
||||
)
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
@@ -252,7 +281,7 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
|
||||
refresh.resolve()
|
||||
})
|
||||
|
||||
test("worktree deletion sends the project location separately from the target", async ({ page }) => {
|
||||
test("worktree deletion sends the project ID and target without a location", async ({ page }) => {
|
||||
const removed = new Set<string>()
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
@@ -284,8 +313,8 @@ test("worktree deletion sends the project location separately from the target",
|
||||
)
|
||||
await remove.click()
|
||||
const request = await deleting
|
||||
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
expect(request.postDataJSON()).toEqual({ directory: sandboxes[0], force: true })
|
||||
expect(new URL(request.url()).searchParams.has("location[directory]")).toBe(false)
|
||||
expect(request.postDataJSON()).toEqual({ projectID: "proj_settings_demo", directory: sandboxes[0], force: true })
|
||||
await expect(settings.getByText(sandboxes[0], { exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByText("11 worktrees", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -98,7 +98,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
const refreshed = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === "/api/worktree" &&
|
||||
new URL(response.url()).searchParams.get("location[directory]") === root &&
|
||||
new URL(response.url()).searchParams.get("projectID") === projectID &&
|
||||
response.request().method() === "GET",
|
||||
)
|
||||
view.worktrees.push({ directory: workspace, strategy: "git" })
|
||||
@@ -228,7 +228,7 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
|
||||
const loaded = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === "/api/worktree" &&
|
||||
new URL(response.url()).searchParams.get("location[directory]") === root &&
|
||||
new URL(response.url()).searchParams.get("projectID") === projectID &&
|
||||
response.request().method() === "GET",
|
||||
)
|
||||
await page.goto(
|
||||
|
||||
@@ -46,6 +46,7 @@ for (const interaction of ["hover", "focus"] as const) {
|
||||
test(`project Worktrees ${interaction} prefetches only its inventory and reuses the request`, async ({ page }) => {
|
||||
const inventory = Promise.withResolvers<void>()
|
||||
const calls: string[] = []
|
||||
const refreshes: string[] = []
|
||||
const sessions: string[] = []
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/project",
|
||||
@@ -54,13 +55,14 @@ for (const interaction of ["hover", "focus"] as const) {
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
calls.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
|
||||
calls.push(new URL(route.request().url()).searchParams.get("projectID") ?? "")
|
||||
await inventory.promise
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url())
|
||||
if (url.pathname === "/api/worktree/refresh") refreshes.push(request.postDataJSON().projectID)
|
||||
if (url.pathname === "/api/session" && url.searchParams.has("directory"))
|
||||
sessions.push(url.searchParams.get("directory")!)
|
||||
})
|
||||
@@ -74,7 +76,7 @@ for (const interaction of ["hover", "focus"] as const) {
|
||||
await worktrees[interaction]()
|
||||
await requested
|
||||
await expect(worktrees).toHaveAttribute("aria-selected", "false")
|
||||
await expect.poll(() => calls).toEqual([directory])
|
||||
await expect.poll(() => calls).toEqual([project.id])
|
||||
expect(sessions).toEqual([])
|
||||
|
||||
if (interaction === "hover") {
|
||||
@@ -90,7 +92,8 @@ for (const interaction of ["hover", "focus"] as const) {
|
||||
inventory.resolve()
|
||||
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
|
||||
expect(calls).toEqual([directory])
|
||||
expect(calls).toEqual([project.id])
|
||||
expect(refreshes).toEqual([project.id])
|
||||
await expect.poll(() => sessions.toSorted()).toEqual(sandboxes.toSorted())
|
||||
})
|
||||
}
|
||||
@@ -113,7 +116,11 @@ for (const nested of [false, true]) {
|
||||
await page.reload()
|
||||
await page.getByTestId("settings-screen").getByRole("tab", { name: "Settings server", exact: true }).click()
|
||||
}
|
||||
const calls = { projects: 0, worktrees: [] as string[] }
|
||||
const calls = { projects: 0, worktrees: [] as string[], refreshes: [] as string[] }
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname === "/api/worktree/refresh")
|
||||
calls.refreshes.push(request.postDataJSON().projectID)
|
||||
})
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/project",
|
||||
async (route) => {
|
||||
@@ -124,9 +131,9 @@ for (const nested of [false, true]) {
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
const requested = new URL(route.request().url()).searchParams.get("location[directory]") ?? ""
|
||||
const requested = new URL(route.request().url()).searchParams.get("projectID") ?? ""
|
||||
calls.worktrees.push(requested)
|
||||
if (requested === other.canonical) return route.fulfill({ json: [{ directory: other.canonical }] })
|
||||
if (requested === other.id) return route.fulfill({ json: [{ directory: other.canonical }] })
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
@@ -141,12 +148,13 @@ for (const nested of [false, true]) {
|
||||
await fetched
|
||||
await worktrees.focus()
|
||||
await expect(worktrees).toHaveAttribute("aria-selected", "false")
|
||||
expect(calls).toEqual({ projects: 1, worktrees: [] })
|
||||
expect(calls).toEqual({ projects: 1, worktrees: [], refreshes: [] })
|
||||
|
||||
await worktrees.click()
|
||||
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
|
||||
expect(calls.projects).toBe(1)
|
||||
expect(calls.worktrees.toSorted()).toEqual([directory, other.canonical].toSorted())
|
||||
expect(calls.worktrees.toSorted()).toEqual([project.id, other.id].toSorted())
|
||||
expect(calls.refreshes).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Worktree } from "@opencode/schema/worktree"
|
||||
|
||||
const Json = Schema.Json.pipe(
|
||||
Schema.decodeTo(Schema.Unknown, {
|
||||
@@ -87,22 +88,25 @@ const Group = HttpApiGroup.make("mock")
|
||||
.add(HttpApiEndpoint.get("websearchProviders", "/api/websearch/provider", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
|
||||
query: Schema.Struct({ projectID: Schema.String }),
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktreeCreate", "/api/worktree", {
|
||||
payload: JsonPayload,
|
||||
payload: Worktree.CreateInput,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree", {
|
||||
payload: Worktree.RemoveInput,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/refresh", {
|
||||
payload: Schema.Struct({ projectID: Schema.String }),
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -297,7 +297,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
})),
|
||||
]),
|
||||
worktreeCreate: (ctx) => {
|
||||
const input = record(ctx.payload) ? ctx.payload : {}
|
||||
const input = ctx.payload
|
||||
return Effect.succeed({
|
||||
directory: `${typeof input.directory === "string" ? input.directory : config.directory}/${
|
||||
typeof input.name === "string" ? input.name : "copy"
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("Composer submission", () => {
|
||||
await committed.promise
|
||||
},
|
||||
command: async (request) => {
|
||||
expect(request).toMatchObject({ command: "review", text: "changes", delivery: "steer" })
|
||||
expect(request).toMatchObject({ name: "review", text: "changes", delivery: "steer" })
|
||||
expect(request).not.toHaveProperty("model")
|
||||
expect(request).not.toHaveProperty("agent")
|
||||
calls.push("command")
|
||||
|
||||
@@ -39,8 +39,7 @@ export function createHomeController() {
|
||||
const id = selectedProject()?.id
|
||||
if (!ctx || !id || ctx.sdk.connection.status() !== "connected") return
|
||||
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
|
||||
const root = ctx.sync.data.project.find((project) => project.id === id)?.worktree
|
||||
if (root) void ctx.sync.worktrees.load(root)
|
||||
void ctx.sync.worktrees.list(id).then(() => ctx.sync.worktrees.refresh(id))
|
||||
})
|
||||
|
||||
function setSelection(next: HomeProjectSelection) {
|
||||
|
||||
@@ -67,12 +67,13 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const [worktrees, worktreeActions] = createResource(worktreeSource, async (source) => ({
|
||||
projectID: source.projectID,
|
||||
items: await serverSDK.api.worktree
|
||||
.list({ location: { directory: source.directory } })
|
||||
.list({ projectID: source.projectID })
|
||||
.catch(() => (currentProject()?.id === source.projectID ? currentProject()?.worktrees : undefined) ?? []),
|
||||
}))
|
||||
onCleanup(
|
||||
serverSDK.event.listen((event) => {
|
||||
if (event.type === "worktree.updated") void worktreeActions.refetch()
|
||||
if (event.type === "worktree.updated" && event.data.projectID === currentProject()?.id)
|
||||
void worktreeActions.refetch()
|
||||
}),
|
||||
)
|
||||
// `latest` only skips Suspense once the resource has resolved at least once. Before that it
|
||||
|
||||
@@ -773,8 +773,8 @@ export const dict = {
|
||||
"session.queue.steerTooltip": "Send without interrupting",
|
||||
"session.queue.remove": "Remove",
|
||||
"session.queue.reorder": "Reorder queued prompt",
|
||||
"session.queue.attachments.one": "Plus {{count}} attachment",
|
||||
"session.queue.attachments.other": "Plus {{count}} attachments",
|
||||
"session.queue.attachments.one": "{{count}} attachment",
|
||||
"session.queue.attachments.other": "{{count}} attachments",
|
||||
"session.timeline.working": "Working",
|
||||
"session.timeline.notice.finished": "{{actor}} finished",
|
||||
"session.timeline.notice.failed": "{{actor}} failed",
|
||||
|
||||
@@ -49,7 +49,7 @@ test("bootstraps projects through the native store setter and preserves subseque
|
||||
expect(store.config).toEqual({})
|
||||
|
||||
// A refetch keeps the inventory a view already loaded for this project.
|
||||
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "/repo/"), [
|
||||
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "project"), [
|
||||
{ directory: "/repo" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
])
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/runtime/server/types"
|
||||
import type {
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
ProjectListOutput,
|
||||
} from "@opencode/client/promise"
|
||||
import type { LocationGetInput, LocationGetOutput, ProjectListOutput } from "@opencode/client/promise"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { retry } from "@opencode/util/retry"
|
||||
@@ -97,7 +93,7 @@ export async function bootstrapGlobal(input: {
|
||||
data.map((project) =>
|
||||
withWorktreeInventory(
|
||||
project,
|
||||
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.worktree)),
|
||||
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.id)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -20,8 +20,7 @@ import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createConnectionSync, reconnectOrder } from "./server-sync/connection"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { Data } from "@opencode/client/solid"
|
||||
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
|
||||
import { sameDirectory } from "@/workspaces/paths"
|
||||
import { createWorktreeInventory, withWorktreeInventory, worktreeInventoryViewKey } from "@/workspaces/inventory"
|
||||
|
||||
type GlobalStore = {
|
||||
path: Path
|
||||
@@ -85,12 +84,19 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
scope: serverSDK.scope,
|
||||
queryClient,
|
||||
api: () => serverSDK.api.worktree,
|
||||
updated: (directory, items) =>
|
||||
updated: (projectID, items) => {
|
||||
setGlobalStore("project", (projects) =>
|
||||
projects.map((project) =>
|
||||
sameDirectory(project.worktree, directory) ? withWorktreeInventory(project, items) : project,
|
||||
),
|
||||
),
|
||||
projects.map((project) => (project.id === projectID ? withWorktreeInventory(project, items) : project)),
|
||||
)
|
||||
for (const key of [
|
||||
worktreeInventoryViewKey(serverSDK.scope, projectID),
|
||||
worktreeInventoryViewKey(serverSDK.scope),
|
||||
]) {
|
||||
queryClient.setQueryData<Project[]>(key, (projects) =>
|
||||
projects?.map((project) => (project.id === projectID ? withWorktreeInventory(project, items) : project)),
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
const bootstrap = useQuery(() => ({
|
||||
queryKey: [serverSDK.scope, "bootstrap"],
|
||||
@@ -164,8 +170,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
|
||||
// The refresh queue re-syncs two directories at a time, held ones first. Syncing every active
|
||||
// directory here as well sent the whole catalog fan-out for all of them at once.
|
||||
reconnectOrder(Object.keys(children.children).filter(children.active), children.pinned).forEach(
|
||||
(directory) => queue.push(directory),
|
||||
reconnectOrder(Object.keys(children.children).filter(children.active), children.pinned).forEach((directory) =>
|
||||
queue.push(directory),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -212,7 +218,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
projects.map((project) =>
|
||||
project.id === update.id
|
||||
? // The wire payload carries no worktrees; keep the inventory this project already loaded.
|
||||
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.canonical))
|
||||
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.id))
|
||||
: project,
|
||||
),
|
||||
)
|
||||
@@ -222,8 +228,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
connection.handleEvent({ type: event.type })
|
||||
if (event.type === "project.updated") applyProjectUpdate(event.data)
|
||||
if (event.type === "worktree.updated") {
|
||||
const root = globalStore.project.find((project) => project.id === event.data.projectID)?.worktree
|
||||
if (root) void worktrees.refresh(root)
|
||||
void worktrees.list(event.data.projectID)
|
||||
void bootstrap.refetch()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInboxInfo } from "@opencode/client/promise"
|
||||
import { queuedPromptRows } from "./queue"
|
||||
import { queuedPromptAttachments, queuedPromptRows } from "./queue"
|
||||
|
||||
const queued = [
|
||||
{
|
||||
@@ -52,3 +52,55 @@ describe("queuedPromptRows", () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("queuedPromptAttachments", () => {
|
||||
test("returns inline attachments as composer image parts", () => {
|
||||
const item = {
|
||||
...queued[0],
|
||||
payload: {
|
||||
text: "",
|
||||
files: [
|
||||
{ data: "aGk=", mime: "image/png", source: { type: "inline" as const }, name: "shot.png" },
|
||||
{ data: "aGk=", mime: "application/pdf", source: { type: "inline" as const } },
|
||||
],
|
||||
},
|
||||
} satisfies SessionInboxInfo
|
||||
|
||||
expect(queuedPromptAttachments(item)).toEqual([
|
||||
{
|
||||
type: "image",
|
||||
id: "msg_original:file:0",
|
||||
filename: "shot.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "data:image/png;base64,aGk=", url: "data:image/png;base64,aGk=" },
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
id: "msg_original:file:1",
|
||||
filename: "attachment",
|
||||
mime: "application/pdf",
|
||||
blob: { id: "data:application/pdf;base64,aGk=", url: "data:application/pdf;base64,aGk=" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("leaves file mentions and context files in the payload", () => {
|
||||
const item = {
|
||||
...queued[0],
|
||||
payload: {
|
||||
text: "see @src/a.ts",
|
||||
files: [
|
||||
{
|
||||
data: "aGk=",
|
||||
mime: "text/plain",
|
||||
source: { type: "inline" as const },
|
||||
mention: { start: 4, end: 13, text: "@src/a.ts" },
|
||||
},
|
||||
{ data: "aGk=", mime: "text/plain", source: { type: "uri" as const, uri: "file:///src/b.ts" } },
|
||||
],
|
||||
},
|
||||
} satisfies SessionInboxInfo
|
||||
|
||||
expect(queuedPromptAttachments(item)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ComposerStateTarget } from "@/composer/submission-state"
|
||||
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
|
||||
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
|
||||
import { buildPromptRequest } from "@/composer/request"
|
||||
import { blobDataUrl } from "@/runtime/persistence/drafts"
|
||||
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
@@ -159,7 +159,10 @@ export function createSessionQueue(input: {
|
||||
})
|
||||
const text = queuedPromptText(item)
|
||||
input.draft.mode.set("normal")
|
||||
input.draft.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
input.draft.set(
|
||||
[{ type: "text", content: text, start: 0, end: text.length }, ...queuedPromptAttachments(item)],
|
||||
text.length,
|
||||
)
|
||||
input.restoreFocus(text.length)
|
||||
return true
|
||||
}
|
||||
@@ -179,9 +182,15 @@ export function createSessionQueue(input: {
|
||||
if (!editing || mutation.isPending) return
|
||||
const prompt = clonePrompt(input.draft.current())
|
||||
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
if (!text.trim() && !prompt.some((part) => part.type === "image")) return cancelEdit()
|
||||
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
if (!text.trim() && !images.length) return cancelEdit()
|
||||
const item = queued().find((entry) => entry.id === editing.id)
|
||||
const pristine = item && text.trim() === queuedPromptText(item) && !prompt.some((part) => part.type === "image")
|
||||
const original = item ? queuedPromptAttachments(item) : []
|
||||
const pristine =
|
||||
item &&
|
||||
text.trim() === queuedPromptText(item) &&
|
||||
images.length === original.length &&
|
||||
images.every((image, index) => image.id === original[index].id)
|
||||
if (pristine && delivery === "queue") return cancelEdit()
|
||||
mutation.mutate({
|
||||
type: "edit",
|
||||
@@ -246,12 +255,32 @@ export function queuedPromptText(item: QueuedPrompt) {
|
||||
return typeof display === "string" && display.length > 0 ? display : item.payload.text
|
||||
}
|
||||
|
||||
// Inline attachments are the files the composer added itself, so they return
|
||||
// to it as image parts that an edit can remove or extend. Mentions and
|
||||
// `file://` context stay in the payload; see editedPromptInput.
|
||||
export function queuedPromptAttachments(item: QueuedPrompt): ImageAttachmentPart[] {
|
||||
return (item.payload.files ?? [])
|
||||
.filter((file) => isComposerAttachment(file))
|
||||
.map((file, index) => ({
|
||||
type: "image",
|
||||
id: `${item.id}:file:${index}`,
|
||||
filename: file.name ?? "attachment",
|
||||
mime: file.mime,
|
||||
blob: createLegacyBlobReference(`data:${file.mime};base64,${file.data}`),
|
||||
}))
|
||||
}
|
||||
|
||||
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
|
||||
return !file.mention && file.source.type === "inline"
|
||||
}
|
||||
|
||||
// Confirming an edit submits the current composer content as the replacement:
|
||||
// mentions and images added during the edit are parsed like a normal
|
||||
// submission, the original's stored attachments are preserved, and the
|
||||
// review-comment notes appended to the original's model-visible text survive.
|
||||
// Ambient composer context (open review comments) stays out: it belongs to
|
||||
// the next fresh prompt, not to a queued edit.
|
||||
// mentions and attachments are parsed like a normal submission, so removed
|
||||
// attachments drop and added ones join. Stored file mentions and context files
|
||||
// the composer cannot show are preserved, and the review-comment notes appended
|
||||
// to the original's model-visible text survive. Ambient composer context (open
|
||||
// review comments) stays out: it belongs to the next fresh prompt, not to a
|
||||
// queued edit.
|
||||
async function editedPromptInput(
|
||||
sessionID: string,
|
||||
directory: string,
|
||||
@@ -297,12 +326,14 @@ async function editedPromptInput(
|
||||
sessionID,
|
||||
text: request.text + notes,
|
||||
files: [
|
||||
...(payload?.files?.map((file) => ({
|
||||
uri: `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: mention(file.mention),
|
||||
})) ?? []),
|
||||
...(payload?.files
|
||||
?.filter((file) => !isComposerAttachment(file))
|
||||
.map((file) => ({
|
||||
uri: `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: mention(file.mention),
|
||||
})) ?? []),
|
||||
...request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||
],
|
||||
agents: agents.map((agent) => ({ name: agent.name, mention: mention(agent.mention) })),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Menu } from "@opencode/ui/menu"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import { createSignal, For, onCleanup, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
@@ -33,17 +33,27 @@ export function SessionWorkspaceMenu(props: {
|
||||
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
|
||||
const workspaces = () =>
|
||||
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
|
||||
const update = (items: Awaited<ReturnType<typeof serverSDK.api.worktree.list>>) =>
|
||||
setDirectories(
|
||||
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
|
||||
)
|
||||
onCleanup(
|
||||
serverSDK.event.listen((event) => {
|
||||
if (event.type !== "worktree.updated" || event.data.projectID !== props.project.id) return
|
||||
void serverSDK.api.worktree
|
||||
.list({ projectID: props.project.id })
|
||||
.then(update)
|
||||
.catch(() => undefined)
|
||||
}),
|
||||
)
|
||||
const onOpenChange = (open: boolean) => {
|
||||
props.onOpenChange?.(open)
|
||||
if (!open) return
|
||||
const sdk = serverSDK
|
||||
void sdk.api.worktree
|
||||
.list({ location: { directory: props.directory } })
|
||||
.then((items) =>
|
||||
setDirectories(
|
||||
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
|
||||
),
|
||||
)
|
||||
.list({ projectID: props.project.id })
|
||||
.then(update)
|
||||
.then(() => sdk.api.worktree.refresh({ projectID: props.project.id }))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
const move = async (selection: "create" | string) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ServerSDK } from "@/runtime/server/client"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useServerCtx, type ServerCtx } from "@/runtime/server/runtime"
|
||||
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
import { worktreeInventoryViewKey } from "@/workspaces/inventory"
|
||||
|
||||
function workspaceProjectsQuery(sdk: ServerSDK) {
|
||||
return queryOptions({
|
||||
@@ -13,18 +14,24 @@ function workspaceProjectsQuery(sdk: ServerSDK) {
|
||||
})
|
||||
}
|
||||
|
||||
export function workspaceInventoryQuery(context: ServerCtx, client: QueryClient, projectID?: string) {
|
||||
export function workspaceInventoryQuery(
|
||||
context: ServerCtx,
|
||||
client: QueryClient,
|
||||
projectID?: string,
|
||||
shouldRefresh = projectID !== undefined,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: [context.sdk.scope, "settings-workspace-inventory", projectID ?? null],
|
||||
queryKey: worktreeInventoryViewKey(context.sdk.scope, projectID),
|
||||
queryFn: async () =>
|
||||
Promise.all(
|
||||
(await client.fetchQuery(workspaceProjectsQuery(context.sdk)))
|
||||
.filter((project) => projectID === undefined || project.id === projectID)
|
||||
.map(async (project) => {
|
||||
const worktrees = (await context.sync.worktrees.load(project.canonical)) ?? [
|
||||
const worktrees = (await context.sync.worktrees.list(project.id)) ?? [
|
||||
{ directory: project.canonical },
|
||||
...project.sandboxes.map((directory) => ({ directory })),
|
||||
]
|
||||
if (shouldRefresh) void context.sync.worktrees.refresh(project.id)
|
||||
return normalizeProjectInfo({ ...project, worktrees })
|
||||
}),
|
||||
),
|
||||
@@ -43,7 +50,7 @@ export function useWorkspacesPrefetch(
|
||||
if (!current || current.sdk.connection.status() !== "connected") return
|
||||
const project = projectID?.()
|
||||
if (project) {
|
||||
void client.prefetchQuery(workspaceInventoryQuery(current, client, project))
|
||||
void client.prefetchQuery(workspaceInventoryQuery(current, client, project, false))
|
||||
return
|
||||
}
|
||||
// Server-level hover warms metadata without booting every project's Location.
|
||||
|
||||
@@ -215,7 +215,7 @@ export const SettingsWorkspaces: Component<{
|
||||
}
|
||||
const removed = await context.sdk.api.worktree
|
||||
.remove({
|
||||
location: { directory: workspace.project.worktree },
|
||||
projectID: workspace.project.id,
|
||||
directory: workspace.directory,
|
||||
force,
|
||||
})
|
||||
@@ -243,7 +243,7 @@ export const SettingsWorkspaces: Component<{
|
||||
})
|
||||
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: worktreeInventoryKey(context.sdk.scope, workspace.project.worktree),
|
||||
queryKey: worktreeInventoryKey(context.sdk.scope, workspace.project.id),
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: [context.sdk.scope, "settings-workspace-inventory"] })
|
||||
} finally {
|
||||
|
||||
@@ -60,13 +60,11 @@ describe("worktree creation", () => {
|
||||
}),
|
||||
).toBe("/created")
|
||||
expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({
|
||||
strategy: "git",
|
||||
projectID: project.id,
|
||||
from: input.canonical,
|
||||
branch: "clone-only",
|
||||
})
|
||||
expect(requests.find((request) => request.method === "POST")?.url).toBe(
|
||||
`http://localhost:3000/api/worktree?location%5Bdirectory%5D=${encodeURIComponent(input.directory)}`,
|
||||
)
|
||||
expect(requests.find((request) => request.method === "POST")?.url).toBe("http://localhost:3000/api/worktree")
|
||||
expect(
|
||||
requests
|
||||
.filter((request) => request.method === "GET")
|
||||
|
||||
@@ -10,8 +10,7 @@ export async function createWorktree(input: {
|
||||
}) {
|
||||
const project = input.project ?? (await input.api.location.get({ location: { directory: input.directory } })).project
|
||||
const created = await input.api.worktree.create({
|
||||
location: { directory: input.directory },
|
||||
strategy: "git",
|
||||
projectID: project.id,
|
||||
from: project.canonical,
|
||||
branch: input.branch,
|
||||
})
|
||||
|
||||
@@ -7,6 +7,8 @@ import { normalizeProjectInfo, updateProjectInfo } from "@/runtime/server/global
|
||||
|
||||
function setup(list: (directory: string) => Promise<WorktreeDirectory[]>) {
|
||||
const client = new QueryClient()
|
||||
const refreshed = Promise.withResolvers<void>()
|
||||
const refreshes: string[] = []
|
||||
const calls: string[] = []
|
||||
const updates: Array<[string, WorktreeDirectory[]]> = []
|
||||
const inventory = createWorktreeInventory({
|
||||
@@ -14,69 +16,90 @@ function setup(list: (directory: string) => Promise<WorktreeDirectory[]>) {
|
||||
queryClient: client,
|
||||
api: () => ({
|
||||
list: (input) => {
|
||||
const directory = input!.location!.directory!
|
||||
const directory = input.projectID
|
||||
calls.push(directory)
|
||||
return list(directory)
|
||||
},
|
||||
refresh: (input) => {
|
||||
refreshes.push(input.projectID)
|
||||
return refreshed.promise
|
||||
},
|
||||
}),
|
||||
updated: (directory, items) => updates.push([directory, items]),
|
||||
updated: (directory, items) => {
|
||||
updates.push([directory, items])
|
||||
},
|
||||
})
|
||||
return { client, calls, updates, inventory }
|
||||
return { client, calls, updates, inventory, refreshed, refreshes }
|
||||
}
|
||||
|
||||
describe("createWorktreeInventory", () => {
|
||||
test("loads once per project, shares in-flight work, and publishes the result", async () => {
|
||||
test("lists a project, shares in-flight work, and publishes each result", async () => {
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const setupResult = setup(async (directory) => {
|
||||
await gate.promise
|
||||
return [{ directory }, { directory: `${directory}/feature`, strategy: "git" }]
|
||||
})
|
||||
const first = setupResult.inventory.load("/repo")
|
||||
const second = setupResult.inventory.load("/repo/")
|
||||
const first = setupResult.inventory.list("/repo")
|
||||
const second = setupResult.inventory.list("/repo")
|
||||
expect(setupResult.calls).toEqual(["/repo"])
|
||||
gate.resolve()
|
||||
expect(await first).toHaveLength(2)
|
||||
expect(await second).toHaveLength(2)
|
||||
await setupResult.inventory.load("/repo")
|
||||
expect(setupResult.calls).toEqual(["/repo"])
|
||||
await setupResult.inventory.list("/repo")
|
||||
expect(setupResult.calls).toEqual(["/repo", "/repo"])
|
||||
expect(setupResult.updates).toEqual([
|
||||
["/repo", [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]],
|
||||
["/repo", [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]],
|
||||
])
|
||||
expect(setupResult.inventory.cached("/repo/")).toHaveLength(2)
|
||||
expect(setupResult.inventory.cached("/repo")).toHaveLength(2)
|
||||
expect(setupResult.refreshes).toEqual([])
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("refreshes only inventories a view already loaded", async () => {
|
||||
test("list reads saved inventory without discovery", async () => {
|
||||
const setupResult = setup(async (directory) => [{ directory }])
|
||||
await setupResult.inventory.refresh("/never-opened")
|
||||
expect(setupResult.calls).toEqual([])
|
||||
await setupResult.inventory.load("/opened")
|
||||
await setupResult.inventory.refresh("/opened")
|
||||
expect(setupResult.calls).toEqual(["/opened", "/opened"])
|
||||
await setupResult.inventory.list("/opened")
|
||||
expect(setupResult.calls).toEqual(["/opened"])
|
||||
expect(setupResult.refreshes).toEqual([])
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("a failed load is not cached and never rejects the caller", async () => {
|
||||
test("a failed list is not cached and never rejects the caller", async () => {
|
||||
let fail = true
|
||||
const setupResult = setup(async (directory) => {
|
||||
if (fail) throw new Error("Location unavailable")
|
||||
return [{ directory }]
|
||||
})
|
||||
expect(await setupResult.inventory.load("/repo")).toBeUndefined()
|
||||
expect(await setupResult.inventory.list("/repo")).toBeUndefined()
|
||||
expect(setupResult.inventory.cached("/repo")).toBeUndefined()
|
||||
fail = false
|
||||
expect(await setupResult.inventory.load("/repo")).toEqual([{ directory: "/repo" }])
|
||||
expect(await setupResult.inventory.list("/repo")).toEqual([{ directory: "/repo" }])
|
||||
expect(setupResult.calls).toEqual(["/repo", "/repo"])
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("keys are partitioned by server and normalized by path", () => {
|
||||
test("keys are partitioned by server and use opaque project IDs", () => {
|
||||
const remote = "https://remote.example" as typeof ServerScope.local
|
||||
expect(worktreeInventoryKey(ServerScope.local, "C:\\Repo\\")).toEqual(
|
||||
worktreeInventoryKey(ServerScope.local, "C:/Repo"),
|
||||
expect(worktreeInventoryKey(ServerScope.local, "project")).not.toEqual(
|
||||
worktreeInventoryKey(ServerScope.local, "project/"),
|
||||
)
|
||||
expect(worktreeInventoryKey(ServerScope.local, "/repo")).not.toEqual(worktreeInventoryKey(remote, "/repo"))
|
||||
})
|
||||
|
||||
test("refresh discovers without changing cached inventory until the next list", async () => {
|
||||
const rows = [{ directory: "/repo" }]
|
||||
const result = setup(async () => [...rows])
|
||||
expect(await result.inventory.list("project")).toEqual(rows)
|
||||
const pending = result.inventory.refresh("project")
|
||||
rows.push({ directory: "/external" })
|
||||
expect(result.inventory.cached("project")).toEqual([{ directory: "/repo" }])
|
||||
result.refreshed.resolve()
|
||||
expect(await pending).toBeUndefined()
|
||||
expect(result.calls).toEqual(["project"])
|
||||
expect(result.refreshes).toEqual(["project"])
|
||||
expect(await result.inventory.list("project")).toEqual(rows)
|
||||
result.client.clear()
|
||||
})
|
||||
})
|
||||
|
||||
describe("withWorktreeInventory", () => {
|
||||
|
||||
@@ -3,11 +3,14 @@ import type { WorktreeDirectory } from "@opencode/client/promise"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { pathKey } from "./path-key"
|
||||
import { sameDirectory } from "./paths"
|
||||
|
||||
export function worktreeInventoryKey(scope: ServerScope, directory: string) {
|
||||
return [scope, "worktree", pathKey(directory)] as const
|
||||
export function worktreeInventoryKey(scope: ServerScope, projectID: string) {
|
||||
return [scope, "worktree", projectID] as const
|
||||
}
|
||||
|
||||
export function worktreeInventoryViewKey(scope: ServerScope, projectID?: string) {
|
||||
return [scope, "settings-workspace-inventory", projectID ?? null] as const
|
||||
}
|
||||
|
||||
// Project metadata arrives without worktrees; a loaded inventory supplies the workspace list.
|
||||
@@ -22,37 +25,37 @@ export function withWorktreeInventory(project: Project, worktrees: readonly Work
|
||||
}
|
||||
}
|
||||
|
||||
// Listing a project's worktrees boots its Location on the server and runs discovery, so only
|
||||
// projects the user is looking at are loaded. Historical projects stay metadata-only.
|
||||
// Reads use saved inventory; explicit demand also discovers external worktree changes.
|
||||
export function createWorktreeInventory(input: {
|
||||
scope: ServerScope
|
||||
queryClient: QueryClient
|
||||
api: () => Pick<ServerApi["worktree"], "list">
|
||||
updated: (directory: string, worktrees: WorktreeDirectory[]) => void
|
||||
api: () => Pick<ServerApi["worktree"], "list" | "refresh">
|
||||
updated: (projectID: string, worktrees: WorktreeDirectory[]) => void
|
||||
}) {
|
||||
const options = (directory: string) => ({
|
||||
queryKey: worktreeInventoryKey(input.scope, directory),
|
||||
const options = (projectID: string) => ({
|
||||
queryKey: worktreeInventoryKey(input.scope, projectID),
|
||||
queryFn: () =>
|
||||
input
|
||||
.api()
|
||||
.list({ location: { directory } })
|
||||
.list({ projectID })
|
||||
.then((items) => {
|
||||
input.updated(directory, items)
|
||||
input.updated(projectID, items)
|
||||
return items
|
||||
}),
|
||||
// `worktree.updated` and reconnect invalidation drive refreshes; time alone does not re-list.
|
||||
// `worktree.updated` and reconnect invalidation drive list reloads; time alone does not re-list.
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
retry: false,
|
||||
})
|
||||
return {
|
||||
cached: (directory: string) =>
|
||||
input.queryClient.getQueryData<WorktreeDirectory[]>(worktreeInventoryKey(input.scope, directory)),
|
||||
load: (directory: string) => input.queryClient.fetchQuery(options(directory)).catch(() => undefined),
|
||||
// Only inventories some view already demanded are refreshed.
|
||||
refresh: (directory: string) => {
|
||||
if (!input.queryClient.getQueryState(worktreeInventoryKey(input.scope, directory))) return Promise.resolve()
|
||||
return input.queryClient.fetchQuery({ ...options(directory), staleTime: 0 }).catch(() => undefined)
|
||||
},
|
||||
cached: (projectID: string) =>
|
||||
input.queryClient.getQueryData<WorktreeDirectory[]>(worktreeInventoryKey(input.scope, projectID)),
|
||||
list: (projectID: string) =>
|
||||
input.queryClient.fetchQuery({ ...options(projectID), staleTime: 0 }).catch(() => undefined),
|
||||
refresh: (projectID: string) =>
|
||||
input
|
||||
.api()
|
||||
.refresh({ projectID })
|
||||
.catch(() => undefined),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +45,7 @@ const context = createSimpleContext({
|
||||
const id = current()?.project.id
|
||||
if (!id || serverSDK.connection.status() !== "connected") return
|
||||
// Showing a Location is the demand for its project's worktree inventory (workspace styling, picker).
|
||||
// Key it by the metadata root so the result merges into the same global project record.
|
||||
const root = server.ctx.sync.data.project.find((project) => project.id === id)?.worktree
|
||||
if (root) void server.ctx.sync.worktrees.load(root)
|
||||
void server.ctx.sync.worktrees.list(id).then(() => server.ctx.sync.worktrees.refresh(id))
|
||||
})
|
||||
|
||||
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
|
||||
|
||||
@@ -143,8 +143,10 @@ function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
|
||||
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
|
||||
}
|
||||
|
||||
// A loose plugin file has no node_modules: the compiled executable must serve
|
||||
// its bundled SDK to this import.
|
||||
function pluginSource() {
|
||||
return 'export default { id: "smoke", setup: async () => {} }\n'
|
||||
return 'import { Plugin } from "@opencode/plugin"\nexport default Plugin.define({ id: "smoke", setup: async () => {} })\n'
|
||||
}
|
||||
|
||||
async function pluginIDs(url: string, headers: HeadersInit) {
|
||||
|
||||
@@ -2166,33 +2166,32 @@ export interface ReferenceApi<E = never> {
|
||||
readonly list: ReferenceListOperation<E>
|
||||
}
|
||||
|
||||
export type WorktreeListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type WorktreeListInput = { readonly projectID: Project.ID }
|
||||
export type WorktreeListOutput = Worktree.List
|
||||
export type WorktreeListOperation<E = never> = (input?: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
|
||||
export type WorktreeListOperation<E = never> = (input: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
|
||||
|
||||
export type WorktreeCreateInput = {
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly strategy?: Worktree.StrategyID | undefined
|
||||
readonly projectID: Project.ID
|
||||
readonly from?: AbsolutePath | undefined
|
||||
readonly branch?: string | undefined
|
||||
readonly directory?: AbsolutePath | undefined
|
||||
readonly name?: string | undefined
|
||||
}
|
||||
export type WorktreeCreateOutput = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input?: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
|
||||
export type WorktreeCreateOperation<E = never> = (input: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
|
||||
|
||||
export type WorktreeRemoveInput = {
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly projectID: Project.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly force: boolean
|
||||
}
|
||||
export type WorktreeRemoveOutput = void
|
||||
export type WorktreeRemoveOperation<E = never> = (input: WorktreeRemoveInput) => Effect.Effect<WorktreeRemoveOutput, E>
|
||||
|
||||
export type WorktreeRefreshInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type WorktreeRefreshInput = { readonly projectID: Project.ID }
|
||||
export type WorktreeRefreshOutput = void
|
||||
export type WorktreeRefreshOperation<E = never> = (
|
||||
input?: WorktreeRefreshInput,
|
||||
input: WorktreeRefreshInput,
|
||||
) => Effect.Effect<WorktreeRefreshOutput, E>
|
||||
|
||||
export interface WorktreeApi<E = never> {
|
||||
|
||||
@@ -1433,21 +1433,20 @@ const EndpointReferenceList = (raw: RawClient["server.reference"]) => (input?: R
|
||||
|
||||
const adaptGroupReference = (raw: RawClient["server.reference"]) => ({ list: EndpointReferenceList(raw) })
|
||||
|
||||
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input?: WorktreeListInput) =>
|
||||
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input: WorktreeListInput) =>
|
||||
preserveEffect<WorktreeListOutput>()(
|
||||
raw["worktree.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["worktree.list"]({ query: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input?: WorktreeCreateInput) =>
|
||||
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: WorktreeCreateInput) =>
|
||||
preserveEffect<WorktreeCreateOutput>()(
|
||||
raw["worktree.create"]({
|
||||
query: { location: input?.["location"] },
|
||||
payload: {
|
||||
strategy: input?.["strategy"],
|
||||
from: input?.["from"],
|
||||
branch: input?.["branch"],
|
||||
directory: input?.["directory"],
|
||||
name: input?.["name"],
|
||||
projectID: input["projectID"],
|
||||
from: input["from"],
|
||||
branch: input["branch"],
|
||||
directory: input["directory"],
|
||||
name: input["name"],
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
@@ -1455,14 +1454,13 @@ const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input?: W
|
||||
const EndpointWorktreeRemove = (raw: RawClient["server.worktree"]) => (input: WorktreeRemoveInput) =>
|
||||
preserveEffect<WorktreeRemoveOutput>()(
|
||||
raw["worktree.remove"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { directory: input["directory"], force: input["force"] },
|
||||
payload: { projectID: input["projectID"], directory: input["directory"], force: input["force"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input?: WorktreeRefreshInput) =>
|
||||
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input: WorktreeRefreshInput) =>
|
||||
preserveEffect<WorktreeRefreshOutput>()(
|
||||
raw["worktree.refresh"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["worktree.refresh"]({ payload: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
|
||||
|
||||
@@ -1952,33 +1952,32 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
worktree: {
|
||||
list: (input?: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/worktree`,
|
||||
query: { location: input?.["location"] },
|
||||
query: { projectID: input["projectID"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
create: (input?: WorktreeCreateInput, requestOptions?: RequestOptions) =>
|
||||
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeCreateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/worktree`,
|
||||
query: { location: input?.["location"] },
|
||||
body: {
|
||||
strategy: input?.["strategy"],
|
||||
from: input?.["from"],
|
||||
branch: input?.["branch"],
|
||||
directory: input?.["directory"],
|
||||
name: input?.["name"],
|
||||
projectID: input["projectID"],
|
||||
from: input["from"],
|
||||
branch: input["branch"],
|
||||
directory: input["directory"],
|
||||
name: input["name"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -1988,22 +1987,21 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/worktree`,
|
||||
query: { location: input["location"] },
|
||||
body: { directory: input["directory"], force: input["force"] },
|
||||
body: { projectID: input["projectID"], directory: input["directory"], force: input["force"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
refresh: (input?: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
|
||||
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeRefreshOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/worktree/refresh`,
|
||||
query: { location: input?.["location"] },
|
||||
body: { projectID: input["projectID"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -6065,44 +6065,41 @@ export type ReferenceListInput = {
|
||||
|
||||
export type ReferenceListOutput = { location: LocationPublicRef; data: Array<ReferenceInfo> }
|
||||
|
||||
export type WorktreeListInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
|
||||
export type WorktreeListOutput = WorktreeList
|
||||
|
||||
export type WorktreeCreateInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
readonly strategy?: {
|
||||
readonly strategy?: string
|
||||
readonly projectID: {
|
||||
readonly projectID: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["strategy"]
|
||||
}["projectID"]
|
||||
readonly from?: {
|
||||
readonly strategy?: string
|
||||
readonly projectID: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["from"]
|
||||
readonly branch?: {
|
||||
readonly strategy?: string
|
||||
readonly projectID: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["branch"]
|
||||
readonly directory?: {
|
||||
readonly strategy?: string
|
||||
readonly projectID: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["directory"]
|
||||
readonly name?: {
|
||||
readonly strategy?: string
|
||||
readonly projectID: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
@@ -6113,16 +6110,14 @@ export type WorktreeCreateInput = {
|
||||
export type WorktreeCreateOutput = WorktreeInfo
|
||||
|
||||
export type WorktreeRemoveInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
|
||||
readonly force: { readonly directory: string; readonly force: boolean }["force"]
|
||||
readonly projectID: { readonly projectID: string; readonly directory: string; readonly force: boolean }["projectID"]
|
||||
readonly directory: { readonly projectID: string; readonly directory: string; readonly force: boolean }["directory"]
|
||||
readonly force: { readonly projectID: string; readonly directory: string; readonly force: boolean }["force"]
|
||||
}
|
||||
|
||||
export type WorktreeRemoveOutput = void
|
||||
|
||||
export type WorktreeRefreshInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
|
||||
export type WorktreeRefreshOutput = void
|
||||
|
||||
|
||||
@@ -332,7 +332,7 @@ test("file.read returns binary content from the public HTTP contract", async ()
|
||||
)
|
||||
})
|
||||
|
||||
test("all worktree operations use location-based routes without a project parameter", async () => {
|
||||
test("all worktree operations require a project ID", async () => {
|
||||
const requests: Request[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
@@ -340,71 +340,75 @@ test("all worktree operations use location-based routes without a project parame
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
if (request.method === "GET") return Response.json([{ directory: "/tmp/project" }])
|
||||
if (request.method === "POST" && !request.url.endsWith("/refresh"))
|
||||
if (request.method === "POST" && new URL(request.url).pathname === "/api/worktree")
|
||||
return Response.json({ directory: "/tmp/worktrees/api" })
|
||||
if (request.method === "POST") return new Response(null, { status: 204 })
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.worktree.list()).toEqual([{ directory: "/tmp/project" }])
|
||||
expect(await client.worktree.list({ projectID: "project" })).toEqual([{ directory: "/tmp/project" }])
|
||||
expect(
|
||||
await client.worktree.create({
|
||||
strategy: "git",
|
||||
projectID: "project",
|
||||
directory: "/tmp/worktrees",
|
||||
name: "api",
|
||||
}),
|
||||
).toEqual({ directory: "/tmp/worktrees/api" })
|
||||
await client.worktree.remove({
|
||||
projectID: "project",
|
||||
directory: "/tmp/worktrees/api",
|
||||
force: false,
|
||||
})
|
||||
await client.worktree.refresh()
|
||||
await client.worktree.refresh({ projectID: "project" })
|
||||
|
||||
expect(requests.map((request) => [request.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/worktree"],
|
||||
["GET", "http://localhost:3000/api/worktree?projectID=project"],
|
||||
["POST", "http://localhost:3000/api/worktree"],
|
||||
["DELETE", "http://localhost:3000/api/worktree"],
|
||||
["POST", "http://localhost:3000/api/worktree/refresh"],
|
||||
])
|
||||
expect(await requests[1]?.json()).toEqual({
|
||||
strategy: "git",
|
||||
projectID: "project",
|
||||
directory: "/tmp/worktrees",
|
||||
name: "api",
|
||||
})
|
||||
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
|
||||
expect(await requests[2]?.json()).toEqual({ projectID: "project", directory: "/tmp/worktrees/api", force: false })
|
||||
expect(await requests[3]?.json()).toEqual({ projectID: "project" })
|
||||
})
|
||||
|
||||
test("worktree operations send the configuration location separately from their payload", async () => {
|
||||
test("worktree operations use the explicit project even with default location headers", async () => {
|
||||
const requests: Request[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
headers: { "x-opencode-directory": "/unrelated" },
|
||||
fetch: async (input, init) => {
|
||||
const request = new Request(input, init)
|
||||
requests.push(request)
|
||||
if (request.method === "GET") return Response.json([{ directory: "/configured/task", strategy: "git" }])
|
||||
if (request.method === "DELETE" || new URL(request.url).pathname.endsWith("/refresh"))
|
||||
return new Response(null, { status: 204 })
|
||||
if (request.method === "DELETE") return new Response(null, { status: 204 })
|
||||
if (new URL(request.url).pathname.endsWith("/refresh")) return new Response(null, { status: 204 })
|
||||
return Response.json({ directory: "/configured/task" })
|
||||
},
|
||||
})
|
||||
expect(await client.worktree.create({ location: { directory: "/repo/nested" }, name: "task" })).toEqual({
|
||||
expect(await client.worktree.create({ projectID: "project", name: "task" })).toEqual({
|
||||
directory: "/configured/task",
|
||||
})
|
||||
expect(requests[0]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
expect(await requests[0]?.json()).toEqual({ name: "task" })
|
||||
expect(requests[0]?.url).toBe("http://localhost:3000/api/worktree")
|
||||
expect(await requests[0]?.json()).toEqual({ projectID: "project", name: "task" })
|
||||
await client.worktree.remove({
|
||||
location: { directory: "/repo/nested" },
|
||||
projectID: "project",
|
||||
directory: "/configured/task",
|
||||
force: true,
|
||||
})
|
||||
await client.worktree.refresh({ location: { directory: "/repo/nested" } })
|
||||
expect(requests[1]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
expect(await requests[1]?.json()).toEqual({ directory: "/configured/task", force: true })
|
||||
expect(requests[2]?.url).toBe("http://localhost:3000/api/worktree/refresh?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
expect(await client.worktree.list({ location: { directory: "/repo/nested" } })).toEqual([
|
||||
await client.worktree.refresh({ projectID: "project" })
|
||||
expect(requests[1]?.url).toBe("http://localhost:3000/api/worktree")
|
||||
expect(await requests[1]?.json()).toEqual({ projectID: "project", directory: "/configured/task", force: true })
|
||||
expect(requests[2]?.url).toBe("http://localhost:3000/api/worktree/refresh")
|
||||
expect(await client.worktree.list({ projectID: "project" })).toEqual([
|
||||
{ directory: "/configured/task", strategy: "git" },
|
||||
])
|
||||
expect(requests[3]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
expect(requests[3]?.url).toBe("http://localhost:3000/api/worktree?projectID=project")
|
||||
})
|
||||
|
||||
test("shell list and remove use the public HTTP contract", async () => {
|
||||
|
||||
@@ -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:",
|
||||
|
||||
+207
-115
@@ -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
|
||||
type Overlays = {
|
||||
settings?: Provider.Settings
|
||||
headers?: Record<string, string>
|
||||
body?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type Target<ID extends string> = Overlays & {
|
||||
package?: string
|
||||
variants?: (Overlays & { id: ID })[]
|
||||
}
|
||||
|
||||
type Context = { readonly providerID: string; readonly canonical?: string; readonly modelID?: string }
|
||||
|
||||
export function rewrite<ID extends string>(
|
||||
target: Target<ID>,
|
||||
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<Record<string, string>> = {
|
||||
"@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`,
|
||||
})
|
||||
|
||||
const HOSTS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
||||
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"),
|
||||
}
|
||||
|
||||
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<Record<string, string>>
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export interface MapInput {
|
||||
readonly packageName: string | undefined
|
||||
readonly settings: Provider.Settings
|
||||
readonly modelID: string
|
||||
readonly providerID: string
|
||||
}
|
||||
|
||||
// A wrongly typed legacy value is dropped rather than failing the whole decode.
|
||||
const lenient = <S extends Schema.Top>(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),
|
||||
})
|
||||
|
||||
/** 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)
|
||||
|
||||
/** 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 } : {}),
|
||||
}
|
||||
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<Mapping, "body"> {
|
||||
function bedrockRequest(modelID: string | undefined, settings: Legacy): Pick<Overlay, "body"> {
|
||||
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<Mapping, "body"
|
||||
// native package would otherwise forward to the request body.
|
||||
const OPENROUTER_KEYS = ["appName", "appUrl", "api_keys", "compatibility"] as const
|
||||
|
||||
function openRouterRequest(settings: Legacy): Pick<Mapping, "headers"> {
|
||||
function openRouterRequest(settings: Legacy): Pick<Overlay, "headers"> {
|
||||
const headers =
|
||||
Provider.mergeHeaders(
|
||||
{
|
||||
@@ -218,3 +256,57 @@ function openRouterRequest(settings: Legacy): Pick<Mapping, "headers"> {
|
||||
) ?? {}
|
||||
return Object.keys(headers).length === 0 ? {} : { headers }
|
||||
}
|
||||
|
||||
const lenient = <S extends Schema.Top>(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)
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Config } from "../../config.js"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Location } from "../../location.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
import { Worktree } from "../../worktree.js"
|
||||
import { WorktreeStrategies } from "../../worktree/strategies.js"
|
||||
import { ConfigEntryObserver } from "./entry-observer.js"
|
||||
|
||||
export const Plugin = define({
|
||||
@@ -16,7 +16,7 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
const worktrees = yield* WorktreeStrategies.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, worktrees.reload())
|
||||
yield* worktrees.transform((editor) => {
|
||||
for (const entry of loaded.entries) {
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -26,8 +26,7 @@ import { Plugin } from "./plugin.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { InstancePlugins } from "./plugin/instance.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { WorktreeRefresh } from "./worktree/refresh.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { WorktreeStrategies } from "./worktree/strategies.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
@@ -74,8 +73,7 @@ const nodes = [
|
||||
PluginHooks.node,
|
||||
InstancePlugins.node,
|
||||
PluginSupervisor.node,
|
||||
WorktreeRefresh.node,
|
||||
Worktree.node,
|
||||
WorktreeStrategies.node,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
ShellSelect.node,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+38
-305
@@ -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<Cost, "tiers" | "context_over_200k">
|
||||
}
|
||||
|
||||
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<Variant.Support, { type: "effort" }>
|
||||
)[]
|
||||
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<string, SourceProvider>): 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<Model.Info["variants"]> {
|
||||
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 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)
|
||||
)
|
||||
return { reasoningEffort: effort }
|
||||
}
|
||||
|
||||
function budgetVariants(
|
||||
npm: string,
|
||||
model: SourceModel,
|
||||
option: Extract<NonNullable<SourceModel["reasoning_options"]>[number], { type: "budget_tokens" }>,
|
||||
): NonNullable<Model.Info["variants"]> {
|
||||
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<Model.Info["variants"]> {
|
||||
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 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,
|
||||
)
|
||||
}
|
||||
|
||||
function modelInfo(
|
||||
providerID: Provider.ID,
|
||||
provider: SourceProvider,
|
||||
id: Model.ID,
|
||||
model: SourceModel,
|
||||
input: {
|
||||
@@ -490,6 +216,13 @@ function modelInfo(
|
||||
readonly variants?: NonNullable<Model.Info["variants"]>
|
||||
} = {},
|
||||
): 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 ?? [])],
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Workspace } from "../workspace.js"
|
||||
import { Vcs } from "../vcs.js"
|
||||
import { WebSearch } from "../websearch.js"
|
||||
import { Worktree } from "../worktree.js"
|
||||
import { WorktreeStrategies } from "../worktree/strategies.js"
|
||||
import { Generate } from "../generate.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { PluginHooks } from "./hooks.js"
|
||||
@@ -71,6 +72,8 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
const persistentPty = yield* PersistentPty.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
const worktreeStrategies = yield* WorktreeStrategies.Service
|
||||
const currentWorktreeStrategies = location.workspaceID ? undefined : worktreeStrategies
|
||||
const locationInfo = () =>
|
||||
new Location.Info({
|
||||
directory: location.directory,
|
||||
@@ -90,21 +93,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
||||
|
||||
const atWorktree = <A, E>(
|
||||
ref: Location.Ref | undefined,
|
||||
run: (service: Worktree.Interface) => Effect.Effect<A, E>,
|
||||
) => {
|
||||
if (ref?.workspaceID) return Effect.fail(new Worktree.UnsupportedLocationError({ directory: ref.directory }))
|
||||
if (!ref || isCurrentLocation(ref)) return run(worktrees)
|
||||
return Effect.gen(function* () {
|
||||
// Defer this import: Plugin's construction depends on this host. Same-location setup calls never wait on themselves.
|
||||
const { Plugin } = yield* Effect.promise(() => import("../plugin.js"))
|
||||
const plugins = yield* Plugin.Service
|
||||
const target = yield* Worktree.Service
|
||||
yield* plugins.awaitActivation
|
||||
return yield* run(target)
|
||||
}).pipe(Effect.provide(locations.get(ref)))
|
||||
}
|
||||
const decodeWorktree = Schema.decodeUnknownEffect(Worktree.Info)
|
||||
const decodeWorktrees = Schema.decodeUnknownEffect(Schema.Array(Worktree.ListEntry))
|
||||
|
||||
@@ -507,13 +495,13 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
}),
|
||||
},
|
||||
worktree: {
|
||||
list: (input) => atWorktree(locationRef(input), (service) => service.list()),
|
||||
create: (input) => atWorktree(locationRef(input), (service) => service.create(input)),
|
||||
refresh: (input) => atWorktree(locationRef(input), (service) => service.refresh()).pipe(Effect.asVoid),
|
||||
remove: (input) => atWorktree(locationRef(input), (service) => service.remove(input)),
|
||||
reload: worktrees.reload,
|
||||
list: worktrees.list,
|
||||
create: (input) => worktrees.create(input, currentWorktreeStrategies),
|
||||
refresh: (input) => worktrees.refresh(input, currentWorktreeStrategies).pipe(Effect.asVoid),
|
||||
remove: (input) => worktrees.remove(input, currentWorktreeStrategies),
|
||||
reload: worktreeStrategies.reload,
|
||||
transform: (callback) =>
|
||||
worktrees.transform((editor) =>
|
||||
worktreeStrategies.transform((editor) =>
|
||||
callback({
|
||||
add: (definition) =>
|
||||
editor.add({
|
||||
@@ -577,6 +565,7 @@ export const requirements = LayerNode.group([
|
||||
Vcs.node,
|
||||
WebSearch.node,
|
||||
Worktree.node,
|
||||
WorktreeStrategies.node,
|
||||
Generate.node,
|
||||
Permission.node,
|
||||
PluginHooks.node,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
|
||||
import { ConfigWorktreePlugin } from "../config/plugin/worktree.js"
|
||||
import { Worktree } from "../worktree.js"
|
||||
import { WorktreeStrategies } from "../worktree/strategies.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
import { FileAccess } from "../file-access.js"
|
||||
@@ -91,7 +92,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"
|
||||
@@ -142,6 +142,7 @@ const services = [
|
||||
Watcher.Service,
|
||||
WellKnown.Service,
|
||||
Worktree.Service,
|
||||
WorktreeStrategies.Service,
|
||||
] as const
|
||||
|
||||
export type Requirements = Context.Service.Identifier<(typeof services)[number]>
|
||||
@@ -192,11 +193,13 @@ export const requirements = LayerNode.group([
|
||||
Watcher.node,
|
||||
WellKnown.node,
|
||||
Worktree.node,
|
||||
WorktreeStrategies.node,
|
||||
])
|
||||
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
BrowserPlugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
McpCodeModeExclusionPlugin.Plugin,
|
||||
@@ -244,8 +247,6 @@ const post = [
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" }
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown>) {
|
||||
// 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<string, string> | undefined)?.["cf-aig-metadata"]
|
||||
return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined
|
||||
}
|
||||
|
||||
function gatewayOptions(options: Record<string, unknown>, 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<string, unknown>, key: string) {
|
||||
return typeof options[key] === "string" ? options[key] : undefined
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>>
|
||||
}) {
|
||||
return Provider.isAISDK(model.package) && typeof model.settings?.baseURL === "string"
|
||||
}
|
||||
|
||||
function sdkOptions(options: Record<string, any>, 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<string, unknown>, key: string) {
|
||||
return typeof options[key] === "string" ? options[key] : undefined
|
||||
}
|
||||
|
||||
@@ -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<typeof fetch>[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) {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<string>(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<Model.Info["variants"]> {
|
||||
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 },
|
||||
}))
|
||||
}
|
||||
@@ -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<string, () => Promise<unknown>>([
|
||||
"@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<string, () => Promise<unknown>>([
|
||||
["@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<string, () => Promise<unknown>>([
|
||||
["@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<Model.Info>
|
||||
(
|
||||
current && models.has(current) && !Object.isFrozen(current)
|
||||
? current
|
||||
: current
|
||||
? structuredClone(current)
|
||||
: Model.Info.default(providerID, modelID)
|
||||
) as DeepMutable<Model.Info>
|
||||
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)
|
||||
},
|
||||
|
||||
@@ -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<Variants[number], "id">
|
||||
|
||||
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<Support, { type: "budget_tokens" }>,
|
||||
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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<Record<string, Protocol>> = {
|
||||
"@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,
|
||||
}
|
||||
+94
-117
@@ -6,13 +6,14 @@ import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { ProjectSchema } from "./project/schema.js"
|
||||
import { Node } from "@opencode/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Slug } from "./util/slug.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { Project } from "./project.js"
|
||||
import { Worktree } from "@opencode/schema/worktree"
|
||||
import { WorktreeTable } from "./worktree/sql.js"
|
||||
import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
@@ -21,7 +22,9 @@ import type { EffectDrizzleSqlite } from "./database/drizzle.js"
|
||||
import { ProjectTable } from "./project/sql.js"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { State } from "./state.js"
|
||||
import { WorktreeStrategies } from "./worktree/strategies.js"
|
||||
|
||||
export type { Strategy, Editor } from "./worktree/strategies.js"
|
||||
|
||||
export { DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
export { OperationError } from "@opencode/schema/worktree"
|
||||
@@ -35,12 +38,6 @@ export type CreateInput = typeof CreateInput.Type
|
||||
export const RemoveInput = Worktree.RemoveInput
|
||||
export type RemoveInput = typeof RemoveInput.Type
|
||||
|
||||
export const RefreshResult = Schema.Struct({
|
||||
updated: Schema.Array(AbsolutePath),
|
||||
removed: Schema.Array(AbsolutePath),
|
||||
}).annotate({ identifier: "Worktree.RefreshResult" })
|
||||
export type RefreshResult = typeof RefreshResult.Type
|
||||
|
||||
export const Info = Worktree.Info
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
@@ -52,7 +49,7 @@ export type ListEntry = typeof ListEntry.Type
|
||||
|
||||
export class SourceDirectoryNotFoundError extends Schema.TaggedError<SourceDirectoryNotFoundError>()(
|
||||
"Worktree.SourceDirectoryNotFoundError",
|
||||
{ projectID: ProjectSchema.ID, directory: Schema.optional(AbsolutePath) },
|
||||
{ projectID: Project.ID, directory: Schema.optional(AbsolutePath) },
|
||||
) {}
|
||||
|
||||
export class DestinationExistsError extends Schema.TaggedError<DestinationExistsError>()(
|
||||
@@ -70,33 +67,17 @@ export class StrategyUnavailableError extends Schema.TaggedError<StrategyUnavail
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export class UnsupportedLocationError extends Schema.TaggedError<UnsupportedLocationError>()(
|
||||
"Worktree.UnsupportedLocationError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| Project.NotFoundError
|
||||
| SourceDirectoryNotFoundError
|
||||
| DestinationExistsError
|
||||
| DirectoryUnavailableError
|
||||
| InvalidDirectoryError
|
||||
| StrategyUnavailableError
|
||||
| UnsupportedLocationError
|
||||
| Worktree.OperationError
|
||||
| AppProcess.AppProcessError
|
||||
| Git.WorktreeError
|
||||
|
||||
export interface Strategy {
|
||||
readonly id: StrategyID
|
||||
readonly create: (input: {
|
||||
sourceDirectory: AbsolutePath
|
||||
directory: AbsolutePath
|
||||
branch?: string
|
||||
}) => Effect.Effect<Info, unknown>
|
||||
readonly remove: (input: { directory: AbsolutePath; force: boolean }) => Effect.Effect<void, unknown>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<readonly ListEntry[], unknown>
|
||||
}
|
||||
|
||||
export const Event = Worktree.Event
|
||||
|
||||
interface StoredInput {
|
||||
@@ -108,19 +89,18 @@ interface StoredInput {
|
||||
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
||||
|
||||
export interface Editor {
|
||||
readonly add: (strategy: Strategy) => void
|
||||
readonly configure: (settings: { readonly directory: AbsolutePath }) => void
|
||||
export interface Interface {
|
||||
readonly list: (input: { projectID: Project.ID }) => Effect.Effect<List, Project.NotFoundError>
|
||||
// The plugin bridge supplies its registry so canonical-project setup can use registrations made so far.
|
||||
readonly create: (input: CreateInput, current?: WorktreeStrategies.Interface) => Effect.Effect<Info, Error>
|
||||
readonly remove: (input: RemoveInput, current?: WorktreeStrategies.Interface) => Effect.Effect<void, Error>
|
||||
readonly refresh: (
|
||||
input: { projectID: Project.ID },
|
||||
current?: WorktreeStrategies.Interface,
|
||||
) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
readonly list: () => Effect.Effect<List, Error>
|
||||
readonly create: (input?: CreateInput) => Effect.Effect<Info, Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: () => Effect.Effect<RefreshResult, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Worktree") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Worktree") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -130,40 +110,29 @@ const layer = Layer.effect(
|
||||
const db = database.db
|
||||
const bus = yield* Bus.Service
|
||||
const processService = yield* AppProcess.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const projectID = location.project.id
|
||||
|
||||
const local = location.workspaceID
|
||||
? Effect.fail(new UnsupportedLocationError({ directory: location.directory }))
|
||||
: Effect.void
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const gitStrategy = yield* WorktreeGit.make
|
||||
const state = State.create({
|
||||
name: "worktree",
|
||||
initial: () => ({
|
||||
directory: AbsolutePath.make(path.join(global.data, "worktree", projectID.slice(0, 6))),
|
||||
strategies: new Map<StrategyID, Strategy>([[gitStrategy.id, gitStrategy]]),
|
||||
selected: gitStrategy.id,
|
||||
}),
|
||||
editor: (value): Editor => ({
|
||||
configure: (settings) => {
|
||||
value.directory = settings.directory
|
||||
},
|
||||
add: (strategy) => {
|
||||
value.strategies.delete(strategy.id)
|
||||
value.strategies.set(strategy.id, strategy)
|
||||
value.selected = strategy.id
|
||||
},
|
||||
}),
|
||||
const project = Effect.fnUntraced(function* (projectID: Project.ID) {
|
||||
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie)
|
||||
if (!row) return yield* new Project.NotFoundError({ projectID })
|
||||
return row
|
||||
})
|
||||
|
||||
const changed = Effect.fnUntraced(function* (update: boolean) {
|
||||
const load = Effect.fnUntraced(function* (directory: AbsolutePath, current?: WorktreeStrategies.Interface) {
|
||||
if (!(yield* fs.isDir(directory))) return yield* new DirectoryUnavailableError({ directory })
|
||||
if (current?.directory === directory) return current.get()
|
||||
const { Plugin } = yield* Effect.promise(() => import("./plugin.js"))
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory }))
|
||||
yield* Context.get(context, Plugin.Service).awaitActivation
|
||||
return Context.get(context, WorktreeStrategies.Service).get()
|
||||
})
|
||||
|
||||
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
|
||||
if (update) yield* bus.publish(Event.Updated, { projectID })
|
||||
})
|
||||
|
||||
const ops = {
|
||||
list: Effect.fnUntraced(function* () {
|
||||
list: Effect.fnUntraced(function* (projectID: Project.ID) {
|
||||
const rows = yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
@@ -173,7 +142,7 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.orDie)
|
||||
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
|
||||
}),
|
||||
find: Effect.fnUntraced(function* (directory: AbsolutePath) {
|
||||
find: Effect.fnUntraced(function* (projectID: Project.ID, directory: AbsolutePath) {
|
||||
const row = yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
@@ -182,7 +151,7 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.orDie)
|
||||
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
|
||||
}),
|
||||
create: (input: StoredInput, tx?: Transaction) =>
|
||||
create: (projectID: Project.ID, input: StoredInput, tx?: Transaction) =>
|
||||
(tx ?? db)
|
||||
.insert(WorktreeTable)
|
||||
.values({
|
||||
@@ -204,7 +173,7 @@ const layer = Layer.effect(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => row !== undefined),
|
||||
),
|
||||
remove: (directory: AbsolutePath, tx?: Transaction) =>
|
||||
remove: (projectID: Project.ID, directory: AbsolutePath, tx?: Transaction) =>
|
||||
(tx ?? db)
|
||||
.delete(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
|
||||
@@ -216,26 +185,28 @@ const layer = Layer.effect(
|
||||
),
|
||||
}
|
||||
|
||||
const source = Effect.fnUntraced(function* (input: AbsolutePath | undefined) {
|
||||
const sourceDirectory = input ?? location.project.directory
|
||||
const source = Effect.fnUntraced(function* (projectID: Project.ID, sourceDirectory: AbsolutePath) {
|
||||
const resolved = yield* canonical(fs, sourceDirectory)
|
||||
if ((yield* ops.find(resolved)) === undefined)
|
||||
if ((yield* ops.find(projectID, resolved)) === undefined)
|
||||
return yield* new SourceDirectoryNotFoundError({ projectID, directory: resolved })
|
||||
return resolved
|
||||
})
|
||||
|
||||
const getStrategy = Effect.fnUntraced(function* (id: StrategyID, strategies: ReadonlyMap<StrategyID, Strategy>) {
|
||||
const getStrategy = Effect.fnUntraced(function* (
|
||||
id: StrategyID,
|
||||
strategies: ReadonlyMap<StrategyID, WorktreeStrategies.Strategy>,
|
||||
) {
|
||||
const found = strategies.get(id)
|
||||
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
|
||||
return found
|
||||
})
|
||||
|
||||
const create = Effect.fn("Worktree.create")(function* (input: CreateInput = {}) {
|
||||
yield* local
|
||||
const current = state.get()
|
||||
const selected = yield* getStrategy(input.strategy ?? current.selected, current.strategies)
|
||||
const directory = input.directory ?? current.directory
|
||||
const sourceDirectory = yield* source(input.from)
|
||||
const create = Effect.fn("Worktree.create")(function* (input: CreateInput, current?: WorktreeStrategies.Interface) {
|
||||
const row = yield* project(input.projectID)
|
||||
const settings = yield* load(row.worktree, current)
|
||||
const selected = yield* getStrategy(settings.selected, settings.strategies)
|
||||
const directory = input.directory ?? settings.directory
|
||||
const sourceDirectory = yield* source(input.projectID, input.from ?? row.worktree)
|
||||
yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie)
|
||||
const name = input.name ?? Slug.create()
|
||||
let suffix = 1
|
||||
@@ -255,19 +226,14 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.mapError((error) => operationError(selected.id, "create", error)))
|
||||
const result = { directory: yield* canonical(fs, created.directory) }
|
||||
yield* changed(
|
||||
yield* ops.create({
|
||||
input.projectID,
|
||||
yield* ops.create(input.projectID, {
|
||||
directory: result.directory,
|
||||
strategy: selected.id,
|
||||
replace: true,
|
||||
}),
|
||||
)
|
||||
const project = yield* db
|
||||
.select({ commands: ProjectTable.commands })
|
||||
.from(ProjectTable)
|
||||
.where(eq(ProjectTable.id, projectID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const command = project?.commands?.start?.trim()
|
||||
const command = row.commands?.start?.trim()
|
||||
if (command) {
|
||||
const windows = process.platform === "win32"
|
||||
yield* processService
|
||||
@@ -286,39 +252,53 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.flatMap(AppProcess.requireSuccess))
|
||||
}
|
||||
return result
|
||||
})
|
||||
}, Effect.scoped)
|
||||
|
||||
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
|
||||
yield* local
|
||||
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput, current?: WorktreeStrategies.Interface) {
|
||||
const row = yield* project(input.projectID)
|
||||
const worktreeDirectory = yield* canonical(fs, input.directory)
|
||||
const stored = yield* ops.find(worktreeDirectory)
|
||||
const stored = yield* ops.find(input.projectID, worktreeDirectory)
|
||||
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
|
||||
const strategy = yield* getStrategy(StrategyID.make(stored.strategy), state.get().strategies)
|
||||
const settings = yield* load(row.worktree, current)
|
||||
const strategy = yield* getStrategy(StrategyID.make(stored.strategy), settings.strategies)
|
||||
yield* strategy
|
||||
.remove({
|
||||
directory: worktreeDirectory,
|
||||
force: input.force,
|
||||
})
|
||||
.pipe(Effect.mapError((error) => operationError(strategy.id, "remove", error)))
|
||||
yield* changed(yield* ops.remove(worktreeDirectory))
|
||||
})
|
||||
yield* changed(input.projectID, yield* ops.remove(input.projectID, worktreeDirectory))
|
||||
}, Effect.scoped)
|
||||
|
||||
const refresh = Effect.fn("Worktree.refresh")(function* () {
|
||||
yield* local
|
||||
const stored = yield* ops.list()
|
||||
const refresh = Effect.fn("Worktree.refresh")(function* (
|
||||
input: { projectID: Project.ID },
|
||||
current?: WorktreeStrategies.Interface,
|
||||
) {
|
||||
const row = yield* project(input.projectID)
|
||||
const settings = yield* load(row.worktree, current)
|
||||
const stored = yield* ops.list(input.projectID)
|
||||
const checked = yield* Effect.forEach(
|
||||
stored,
|
||||
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const strategies = Array.from(state.get().strategies.values()).toReversed()
|
||||
const strategies = Array.from(settings.strategies.values()).toReversed()
|
||||
const discovered = new Map<AbsolutePath, StoredInput>()
|
||||
// A location's plugin instances only discover its own checkout, not sibling clones.
|
||||
if (checked.some((item) => item.directory === location.project.directory && item.exists)) {
|
||||
// Unowned rows are checkout/discovery roots. Managed children are enumerated by their backend.
|
||||
const roots = new Set([
|
||||
row.worktree,
|
||||
...checked.filter((item) => item.exists && !item.strategy).map((item) => item.directory),
|
||||
])
|
||||
for (const directory of roots) {
|
||||
if (!(yield* fs.isDir(directory))) continue
|
||||
for (const strategy of strategies) {
|
||||
const entries = yield* strategy.list(location.project.directory).pipe(
|
||||
const entries = yield* strategy.list(directory).pipe(
|
||||
Effect.mapError((error) => operationError(strategy.id, "list", error)),
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
|
||||
Effect.catch((error) =>
|
||||
Effect.logWarning("worktree discovery failed", { directory, strategy: strategy.id, error }).pipe(
|
||||
Effect.as([]),
|
||||
),
|
||||
),
|
||||
)
|
||||
for (const entry of entries) {
|
||||
const directory = yield* canonical(fs, entry.directory).pipe(
|
||||
@@ -336,23 +316,20 @@ const layer = Layer.effect(
|
||||
const changes = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.all({
|
||||
updated: Effect.filter(Array.from(discovered.values()), (item) => ops.create(item, tx)).pipe(
|
||||
Effect.map((items) => items.map((item) => item.directory)),
|
||||
),
|
||||
removed: Effect.filter(removed, (directory) => ops.remove(directory, tx)),
|
||||
updated: Effect.filter(Array.from(discovered.values()), (item) =>
|
||||
ops.create(input.projectID, item, tx),
|
||||
).pipe(Effect.map((items) => items.map((item) => item.directory))),
|
||||
removed: Effect.filter(removed, (directory) => ops.remove(input.projectID, directory, tx)),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* changed(changes.updated.length > 0 || changes.removed.length > 0)
|
||||
return changes
|
||||
})
|
||||
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
|
||||
}, Effect.scoped)
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list: Effect.fn("Worktree.list")(function* () {
|
||||
yield* refresh()
|
||||
return yield* ops.list()
|
||||
list: Effect.fn("Worktree.list")(function* (input) {
|
||||
yield* project(input.projectID)
|
||||
return yield* ops.list(input.projectID)
|
||||
}),
|
||||
create,
|
||||
remove,
|
||||
@@ -361,10 +338,10 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.global> = Node.makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node, Location.node, Global.node],
|
||||
layer,
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node, LocationServiceMap.node],
|
||||
})
|
||||
|
||||
function operationError(strategy: StrategyID, operation: string, error: unknown) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Worktree } from "@opencode/schema/worktree"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Git } from "../git.js"
|
||||
import { canonical, DirectoryUnavailableError } from "./directory.js"
|
||||
import type { ListEntry, Strategy } from "../worktree.js"
|
||||
import type { Strategy } from "./strategies.js"
|
||||
|
||||
export const make = Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
@@ -33,7 +33,7 @@ export const make = Effect.gen(function* () {
|
||||
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "worktree" }) as const),
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.undefined),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
|
||||
).pipe(Effect.map((items) => items.filter((item): item is Worktree.ListEntry => item !== undefined)))
|
||||
}),
|
||||
} satisfies Strategy
|
||||
})
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
export * as WorktreeRefresh from "./refresh.js"
|
||||
|
||||
import { Effect, Layer } from "effect"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Location } from "../location.js"
|
||||
import { Plugin } from "../plugin.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { Worktree } from "../worktree.js"
|
||||
|
||||
const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
if (location.workspaceID) return
|
||||
yield* plugins.awaitActivation.pipe(
|
||||
Effect.andThen(worktrees.refresh()),
|
||||
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "worktree-refresh",
|
||||
layer,
|
||||
deps: [Worktree.node, Location.node, Plugin.node, PluginSupervisor.node],
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
export * as WorktreeStrategies from "./strategies.js"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Worktree } from "@opencode/schema/worktree"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import { Git } from "../git.js"
|
||||
import { Location } from "../location.js"
|
||||
import { State } from "../state.js"
|
||||
import { WorktreeGit } from "./git.js"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
|
||||
export interface Strategy {
|
||||
readonly id: Worktree.StrategyID
|
||||
readonly create: (input: {
|
||||
sourceDirectory: AbsolutePath
|
||||
directory: AbsolutePath
|
||||
branch?: string
|
||||
}) => Effect.Effect<Worktree.Info, unknown>
|
||||
readonly remove: (input: { directory: AbsolutePath; force: boolean }) => Effect.Effect<void, unknown>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<readonly Worktree.ListEntry[], unknown>
|
||||
}
|
||||
|
||||
export interface Editor {
|
||||
readonly add: (strategy: Strategy) => void
|
||||
readonly configure: (settings: { readonly directory: AbsolutePath }) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
readonly directory: AbsolutePath
|
||||
readonly get: () => {
|
||||
readonly directory: AbsolutePath
|
||||
readonly strategies: ReadonlyMap<Worktree.StrategyID, Strategy>
|
||||
readonly selected: Worktree.StrategyID
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/WorktreeStrategies") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const git = yield* WorktreeGit.make
|
||||
const state = State.create({
|
||||
name: "worktree",
|
||||
initial: () => ({
|
||||
directory: AbsolutePath.make(path.join(global.data, "worktree", location.project.id.slice(0, 6))),
|
||||
strategies: new Map<Worktree.StrategyID, Strategy>([[git.id, git]]),
|
||||
selected: git.id,
|
||||
}),
|
||||
editor: (value): Editor => ({
|
||||
configure: (settings) => {
|
||||
value.directory = settings.directory
|
||||
},
|
||||
add: (strategy) => {
|
||||
value.strategies.delete(strategy.id)
|
||||
value.strategies.set(strategy.id, strategy)
|
||||
value.selected = strategy.id
|
||||
},
|
||||
}),
|
||||
})
|
||||
return Service.of({ ...state, directory: location.directory })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Location.node, Global.node, Git.node, FSUtil.node],
|
||||
})
|
||||
@@ -1,10 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AISDKNative } from "@opencode/core/aisdk-native"
|
||||
|
||||
const map = (packageName: string, settings: Readonly<Record<string, unknown>>, modelID = "test-model") =>
|
||||
AISDKNative.map({ packageName, settings, modelID, providerID: "test-provider" })
|
||||
function map(
|
||||
packageName: string,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
modelID = "test-model",
|
||||
providerID = "test-provider",
|
||||
) {
|
||||
const target: {
|
||||
package?: string
|
||||
settings?: Record<string, unknown>
|
||||
headers?: Record<string, string>
|
||||
body?: Record<string, unknown>
|
||||
} = {
|
||||
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", {
|
||||
|
||||
@@ -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<typeof fetch>[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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] })
|
||||
|
||||
@@ -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<A, E, R>(variables: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
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")
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import { Tool } from "@opencode/core/tool"
|
||||
import { Vcs } from "@opencode/core/vcs"
|
||||
import { WebSearch } from "@opencode/core/websearch"
|
||||
import { Worktree } from "@opencode/core/worktree"
|
||||
import { WorktreeStrategies } from "@opencode/core/worktree/strategies"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
import { emptyMcpLayer } from "../fixture/mcp"
|
||||
@@ -98,6 +99,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
||||
Watcher.node,
|
||||
WebSearch.node,
|
||||
Worktree.node,
|
||||
WorktreeStrategies.node,
|
||||
]),
|
||||
[
|
||||
Location.node.replace(tempLocationLayer),
|
||||
|
||||
@@ -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" } } },
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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" }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>[] = []
|
||||
const unifiedCalls: string[] = []
|
||||
const gatewayModelCalls: unknown[] = []
|
||||
|
||||
function captureAiGatewayOptions(options: Record<string, unknown>) {
|
||||
const nested =
|
||||
options.options && typeof options.options === "object" ? (options.options as Record<string, unknown>) : undefined
|
||||
return {
|
||||
...options,
|
||||
...(nested
|
||||
? {
|
||||
options: {
|
||||
...nested,
|
||||
headers:
|
||||
nested.headers && typeof nested.headers === "object"
|
||||
? { ...(nested.headers as Record<string, unknown>) }
|
||||
: nested.headers,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function resetCalls() {
|
||||
aiGatewayCalls.length = 0
|
||||
unifiedCalls.length = 0
|
||||
gatewayModelCalls.length = 0
|
||||
}
|
||||
|
||||
function cloudflareEnv(overrides: Record<string, string | undefined> = {}) {
|
||||
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<string, unknown>) {
|
||||
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(), () =>
|
||||
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()
|
||||
|
||||
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,
|
||||
},
|
||||
const integrations = yield* Integration.Service
|
||||
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
})
|
||||
|
||||
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(), () =>
|
||||
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" },
|
||||
})
|
||||
|
||||
expect(result.sdk).toBeUndefined()
|
||||
expect(aiGatewayCalls).toHaveLength(0)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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<A, E, R>(vars: Record<string, string | undefined>, 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<string, string> | Promise<Record<string, string>>
|
||||
}
|
||||
|
||||
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,38 +72,33 @@ 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(required(yield* catalog.get(providerID)).package).toBe(
|
||||
"@opencode/ai/providers/cloudflare-workers-ai",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("gives the native Workers AI package its account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seed((provider) => {
|
||||
provider.package = "@opencode/ai/providers/cloudflare-workers-ai"
|
||||
})
|
||||
expect(provider).toMatchObject({
|
||||
package: "aisdk:test-provider",
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" },
|
||||
})
|
||||
expect(sdk.sdk).toBeDefined()
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.get(providerID)).settings).toEqual({ accountId: "acct" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -126,15 +106,12 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
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, baseURL: "https://proxy.example/v1" }
|
||||
}),
|
||||
)
|
||||
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://proxy.example/v1" },
|
||||
})
|
||||
@@ -142,145 +119,19 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses env account ID over configured account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-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, accountId: "configured-acct" }
|
||||
})
|
||||
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" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
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" }, () =>
|
||||
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 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"),
|
||||
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" },
|
||||
})
|
||||
expect(result.sdk).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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<string, any>[] = []
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
@@ -46,29 +45,13 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
)
|
||||
}
|
||||
|
||||
void mock.module("google-auth-library", () => ({
|
||||
GoogleAuth: class {
|
||||
constructor(options: Record<string, any>) {
|
||||
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<typeof fetch>[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<typeof fetch>[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
|
||||
|
||||
@@ -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" }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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" }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"), () => {})
|
||||
|
||||
@@ -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" }
|
||||
})
|
||||
|
||||
@@ -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"), () => {})
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"), () => {})
|
||||
|
||||
@@ -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, () => {})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user