Compare commits

...

9 Commits

Author SHA1 Message Date
Aiden Cline 77f7642d09 fix(plugin): serve bundled SDK to loose plugin files on Bun
Loose plugin files under .opencode/plugins or configured by path have no
node_modules, so the compiled Bun executable failed to resolve
@opencode/plugin. Register the host's own SDK modules as Bun virtual modules
when a plugin loads, matching the module hooks the Node executable already
registers in its bundle prelude.
2026-09-14 22:02:14 -05:00
James Long 081eef3b80 feat(core): make worktree APIs project-based (#48867) 2026-09-14 22:40:16 -04:00
Luke Parker 98f2a87304 fix(app): restore queued attachments to the composer when editing (#49081) 2026-09-15 00:52:08 +00:00
Luke Parker 23402629c5 fix(app): drop 'Plus' prefix from queued attachment label (#49080) 2026-09-15 00:51:32 +00:00
opencode-agent[bot] 90ac6f9724 test(app): match renamed command field (#49077)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-09-14 19:27:50 -05:00
opencode-agent[bot] f02b21a69a chore: update nix node_modules hashes 2026-09-14 23:56:05 +00:00
opencode-agent[bot] 7dcafbfa0b feat(tui): add effort variant alias (#49078)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-14 18:54:59 -05:00
opencode-agent[bot] 2463612418 test(ai): update Cloudflare gateway recordings (#49075)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-09-14 18:42:19 -05:00
Aiden Cline 2226afaea5 refactor(core): refactor model resolving logic, fix missing variant logic (#48943) 2026-09-14 18:29:34 -05:00
137 changed files with 3220 additions and 3386 deletions
+1 -117
View File
@@ -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
View File
@@ -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,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,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,
+65 -83
View File
@@ -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([])
})
}
+5 -1
View File
@@ -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,
}),
)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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")
+1 -2
View File
@@ -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
+2 -2
View File
@@ -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)),
),
),
),
+17 -12
View File
@@ -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([])
})
})
+46 -15
View File
@@ -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 {
+2 -4
View File
@@ -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")
+1 -2
View File
@@ -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,
})
+44 -21
View File
@@ -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", () => {
+23 -20
View File
@@ -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),
}
}
+1 -3
View File
@@ -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))
+3 -1
View File
@@ -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) {
+7 -8
View File
@@ -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> {
+11 -13
View File
@@ -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"]) => ({
+15 -17
View File
@@ -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,
+12 -17
View File
@@ -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
+25 -21
View File
@@ -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 () => {
+1 -7
View File
@@ -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
View File
@@ -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)
+11 -1
View File
@@ -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,
}),
]
})
}
}
})
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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 } : {}),
+2 -4
View File
@@ -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,
+2 -2
View File
@@ -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: {
+6 -18
View File
@@ -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: () => {
+8
View File
@@ -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
View File
@@ -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 ?? [])],
+10 -21
View File
@@ -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,
+4 -3
View File
@@ -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[]
+2
View File
@@ -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]
}
-4
View File
@@ -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",
+10 -4
View File
@@ -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 -3
View File
@@ -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 -3
View File
@@ -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 -3
View File
@@ -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 = {
-41
View File
@@ -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 },
}))
}
+39 -7
View File
@@ -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)
},
+554
View File
@@ -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
View File
@@ -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) {
+2 -2
View File
@@ -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
})
-28
View File
@@ -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],
})
+74
View File
@@ -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],
})
+31 -2
View File
@@ -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", {
-113
View File
@@ -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
+63 -7
View File
@@ -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",
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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"] })
+43 -8
View File
@@ -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")
+17 -7
View File
@@ -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()
}),
)
+2
View File
@@ -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),
+52 -18
View File
@@ -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