Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aa34ab9f3 | ||
|
|
50ef866a02 | ||
|
|
3650fefe2d | ||
|
|
22091c29f1 | ||
|
|
e7e89dc5a6 | ||
|
|
34e9392bb4 | ||
|
|
05c3bc27ff | ||
|
|
b1a6333d17 | ||
|
|
5c9d619620 | ||
|
|
dfb9caa2a9 | ||
|
|
57a2b5f444 | ||
|
|
977c9a3e2c | ||
|
|
db84ee17f4 | ||
|
|
0b1f6a7d2d | ||
|
|
a6d225558c | ||
|
|
2434965b7f | ||
|
|
d4cf78bceb | ||
|
|
ed4ce67cdc | ||
|
|
94dca309e9 | ||
|
|
52e4dd110b | ||
|
|
1e74560796 | ||
|
|
48f2419d9d | ||
|
|
b9ef09a0f4 | ||
|
|
eb81994a18 | ||
|
|
a3819e088c | ||
|
|
324ae9c471 | ||
|
|
7349626757 | ||
|
|
76c25ef286 | ||
|
|
c8b3b31d27 | ||
|
|
81fef60266 | ||
|
|
3fe5d91372 | ||
|
|
7adb6e495a | ||
|
|
2039c6936f | ||
|
|
a02fefe9dc | ||
|
|
cb0e05db26 | ||
|
|
b9cdcaa9db | ||
|
|
94453eb1bd | ||
|
|
8f629db988 | ||
|
|
585378cba0 | ||
|
|
8cd8393339 | ||
|
|
b184b2fb73 | ||
|
|
c88c2da9be | ||
|
|
9b04081ae0 | ||
|
|
7d2d87fa2c | ||
|
|
787f37b382 | ||
|
|
8fa1af851c | ||
|
|
73bc3e704e | ||
|
|
8d2feed30e | ||
|
|
2d8d4e5dee | ||
|
|
b3784588ae | ||
|
|
104d52bc38 | ||
|
|
dff1fe2d28 | ||
|
|
72ab4260ee | ||
|
|
9e9b4a0555 | ||
|
|
e53192889c | ||
|
|
23bbfb3d15 | ||
|
|
37da005a01 | ||
|
|
8b708242f1 | ||
|
|
339d2dcb98 | ||
|
|
bbc8678164 | ||
|
|
a1d54475fe | ||
|
|
55c601d13a | ||
|
|
9115fac4c4 | ||
|
|
cfcb2c1fd8 | ||
|
|
221fc62135 |
@@ -2,6 +2,10 @@
|
||||
|
||||
- To test opencode in the `packages/opencode` directory you can run `bun dev`
|
||||
|
||||
## SDK
|
||||
|
||||
To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
|
||||
|
||||
## Tool Calling
|
||||
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||
|
||||
@@ -30,7 +30,7 @@ scoop bucket add extras; scoop install extras/opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install opencode # macOS and Linux
|
||||
paru -S opencode-bin # Arch Linux
|
||||
mise use -g github:sst/opencode # Any OS
|
||||
mise use -g opencode # Any OS
|
||||
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
|
||||
```
|
||||
|
||||
|
||||
@@ -186,3 +186,4 @@
|
||||
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
|
||||
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
|
||||
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
|
||||
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -70,7 +70,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -125,7 +125,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -149,7 +149,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -173,7 +173,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
@@ -201,7 +201,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -230,7 +230,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -246,7 +246,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -271,6 +271,7 @@
|
||||
"@ai-sdk/provider": "2.0.0",
|
||||
"@ai-sdk/provider-utils": "3.0.19",
|
||||
"@ai-sdk/togetherai": "1.0.30",
|
||||
"@ai-sdk/vercel": "1.0.31",
|
||||
"@ai-sdk/xai": "2.0.42",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
@@ -284,8 +285,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@opentui/core": "0.1.63",
|
||||
"@opentui/solid": "0.1.63",
|
||||
"@opentui/core": "0.1.67",
|
||||
"@opentui/solid": "0.1.67",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -347,7 +348,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -367,7 +368,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.1",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -378,7 +379,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -391,7 +392,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -429,7 +430,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -440,7 +441,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -571,6 +572,8 @@
|
||||
|
||||
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.30", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9bxQbIXnWSN4bNismrza3NvIo+ui/Y3pj3UN6e9vCszCWFCN45RgISi4oDe10RqmzaJ/X8cfO/Tem+K8MT3wGQ=="],
|
||||
|
||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ggvwAMt/KsbqcdR6ILQrjwrRONLV/8aG6rOLbjcOGvV0Ai+WdZRRKQj5nOeQ06PvwVQtKdkp7S4IinpXIhCiHg=="],
|
||||
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.42", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wlwO4yRoZ/d+ca29vN8SDzxus7POdnL7GBTyRdSrt6icUF0hooLesauC8qRUC4aLxtqvMEc1YHtJOU7ZnLWbTQ=="],
|
||||
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
@@ -1193,21 +1196,21 @@
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.63", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.63", "@opentui/core-darwin-x64": "0.1.63", "@opentui/core-linux-arm64": "0.1.63", "@opentui/core-linux-x64": "0.1.63", "@opentui/core-win32-arm64": "0.1.63", "@opentui/core-win32-x64": "0.1.63", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-m4xZQTNCnHXWUWCnGvacJ3Gts1H2aMwP5V/puAG77SDb51jm4W/QOyqAAdgeSakkb9II+8FfUpApX7sfwRXPUg=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.67", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.67", "@opentui/core-darwin-x64": "0.1.67", "@opentui/core-linux-arm64": "0.1.67", "@opentui/core-linux-x64": "0.1.67", "@opentui/core-win32-arm64": "0.1.67", "@opentui/core-win32-x64": "0.1.67", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-zmfyA10QUbzT6ohacPoHmGiYzuJrDSCfQWRWrKtao0BrHj9bii73qWy3V/eR4ibVueoRREwxJs5GlBOSvK6IoA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.63", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jKCThZGiiublKkP/hMtDtl1MLCw5NU0hMNJdEYvz1WLT9bzliWf6Kb7MIDAmk32XlbQW8/RHdp+hGyGDXK62OQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.67", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LtOcTlFD+kO7neItmkiF77H8cnjTYzBOZe8JQGwRSt9aaCke3UzMvLxmQnj4BP/kPC3hi9V6NRnFdptz0sJZIQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.63", "", { "os": "darwin", "cpu": "x64" }, "sha512-rfNxynHzJpxN9i+SAMnn1NToEc8rYj64BsOxY78JNsm4Gg1Js1uyMaawwh2WbdGknFy4cDXS9QwkUMdMcfnjiw=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.67", "", { "os": "darwin", "cpu": "x64" }, "sha512-9i+awVWgpEVqZhFLaLq8usNGyCiyT5QxMLy6eH7JmRic79S34u23HfxiniGRtdYh3aqpm9SbLzo60v0nRIUkCA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.63", "", { "os": "linux", "cpu": "arm64" }, "sha512-wG9d6mHWWKZGrzxYS4c+BrcEGXBv/MYBUPSyjP/lD0CxT+X3h6CYhI317JkRyMNfh3vI9CpAKGFTOFvrTTHimQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.67", "", { "os": "linux", "cpu": "arm64" }, "sha512-WLjnTM3Ig//SRo0FUZYZJ5TITVbR6dKDVg6axU2D+sMoUzJMBP/Xo04q/TvZ3wP764Yca9l7oVMKWDxHlygyjQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.63", "", { "os": "linux", "cpu": "x64" }, "sha512-TKSzFv4BgWW3RB/iZmq5qxTR4/tRaXo8IZNnVR+LFzShbPOqhUi466AByy9SUmCxD8uYjmMDFYfKtkCy0AnAwA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.67", "", { "os": "linux", "cpu": "x64" }, "sha512-5UbZ/TqWi/DAmHIZL4NvhdpgTwglszRiddkRiQ8cT0IbnE4lutd4XxWUWcLKwsNT1YJv32TtcGWkuthluLiriQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.63", "", { "os": "win32", "cpu": "arm64" }, "sha512-CBWPyPognERP0Mq4eC1q01Ado2C2WU+BLTgMdhyt+E2P4w8rPhJ2kCt2MNxO66vQUiynspmZkgjQr0II/VjxWA=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.67", "", { "os": "win32", "cpu": "arm64" }, "sha512-KNam5rObhN8/U9+GVVuvtAlGXp3MfdMHnw4W2P6YH7xp8HTsLvABUT91SJEyJ/ktVe9e1itLDG2fDHSoA5NbUg=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.63", "", { "os": "win32", "cpu": "x64" }, "sha512-qEp6h//FrT+TQiiHm87wZWUwqTPTqIy1ZD+8R+VCUK+usoQiOAD2SqrYnM7W8JkCMGn5/TKm/GaKLyx/qlK4VA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.67", "", { "os": "win32", "cpu": "x64" }, "sha512-740lkOw42zLNh9YfahXjCwV2DS/amH2uMDh3tCADDCLckrMhemIhqArXDiMlalDxDqYspoaZCpBsFVsG9dMS6A=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.63", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.63", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-Gccln4qRucAoaoQEZ4NPAHvGmVYzU/8aKCLG8EPgwCKTcpUzlqYt4357cDHq4cnCNOcXOC06hTz/0pK9r0dqXA=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.67", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.67", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-dVNq0+PJIdNb63D0T7vcbyVF/ZvLCihGvivTU50zDOzd0Sk5prbrIfpG8+DjMErFubXfdZQvdy/PqFdtw0rjtQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -3917,6 +3920,12 @@
|
||||
|
||||
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
|
||||
|
||||
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"aarch64-darwin"
|
||||
"x86_64-darwin"
|
||||
];
|
||||
lib = nixpkgs.lib;
|
||||
inherit (nixpkgs) lib;
|
||||
forEachSystem = lib.genAttrs systems;
|
||||
pkgsFor = system: nixpkgs.legacyPackages.${system};
|
||||
packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
|
||||
@@ -70,12 +70,12 @@
|
||||
in
|
||||
{
|
||||
default = mkPackage {
|
||||
version = packageJson.version;
|
||||
inherit (packageJson) version;
|
||||
src = ./.;
|
||||
scripts = ./nix/scripts;
|
||||
target = bunTarget.${system};
|
||||
modelsDev = "${modelsDev.${system}}/dist/_api.json";
|
||||
mkNodeModules = mkNodeModules;
|
||||
inherit mkNodeModules;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"nodeModules": "sha256-YWKI/BgCEsQwPYahrr+DgkcdMGv29OEm+8SQ1ZKB4S0="
|
||||
"nodeModules": "sha256-7zMUWgMCnoe2As8WdEKazkKiGEcUIk5rP4zFvX9USgA="
|
||||
}
|
||||
|
||||
+18
-10
@@ -1,18 +1,26 @@
|
||||
{ hash, lib, stdenvNoCC, bun, cacert, curl }:
|
||||
{
|
||||
hash,
|
||||
lib,
|
||||
stdenvNoCC,
|
||||
bun,
|
||||
cacert,
|
||||
curl,
|
||||
}:
|
||||
args:
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "opencode-node_modules";
|
||||
version = args.version;
|
||||
src = args.src;
|
||||
inherit (args) version src;
|
||||
|
||||
impureEnvVars =
|
||||
lib.fetchers.proxyImpureEnvVars
|
||||
++ [
|
||||
"GIT_PROXY_COMMAND"
|
||||
"SOCKS_SERVER"
|
||||
];
|
||||
impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [
|
||||
"GIT_PROXY_COMMAND"
|
||||
"SOCKS_SERVER"
|
||||
];
|
||||
|
||||
nativeBuildInputs = [ bun cacert curl ];
|
||||
nativeBuildInputs = [
|
||||
bun
|
||||
cacert
|
||||
curl
|
||||
];
|
||||
|
||||
dontConfigure = true;
|
||||
|
||||
|
||||
+10
-7
@@ -1,7 +1,13 @@
|
||||
{ lib, stdenvNoCC, bun, ripgrep, makeBinaryWrapper }:
|
||||
{
|
||||
lib,
|
||||
stdenvNoCC,
|
||||
bun,
|
||||
ripgrep,
|
||||
makeBinaryWrapper,
|
||||
}:
|
||||
args:
|
||||
let
|
||||
scripts = args.scripts;
|
||||
inherit (args) scripts;
|
||||
mkModules =
|
||||
attrs:
|
||||
args.mkNodeModules (
|
||||
@@ -14,13 +20,10 @@ let
|
||||
in
|
||||
stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
pname = "opencode";
|
||||
version = args.version;
|
||||
|
||||
src = args.src;
|
||||
inherit (args) version src;
|
||||
|
||||
node_modules = mkModules {
|
||||
version = finalAttrs.version;
|
||||
src = finalAttrs.src;
|
||||
inherit (finalAttrs) version src;
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -31,19 +31,16 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const serverDefaults = iife(() => {
|
||||
const defaultServerUrl = iife(() => {
|
||||
const param = new URLSearchParams(document.location.search).get("url")
|
||||
if (param) return { url: param, forced: true }
|
||||
if (param) return param
|
||||
|
||||
if (location.hostname.includes("opencode.ai")) return { url: "http://localhost:4096", forced: false }
|
||||
if (window.__OPENCODE__) return { url: `http://127.0.0.1:${window.__OPENCODE__.port}`, forced: false }
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
|
||||
if (import.meta.env.DEV)
|
||||
return {
|
||||
url: `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`,
|
||||
forced: false,
|
||||
}
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
|
||||
return { url: window.location.origin, forced: false }
|
||||
return window.location.origin
|
||||
})
|
||||
|
||||
function ServerKey(props: ParentProps) {
|
||||
@@ -65,7 +62,7 @@ export function App() {
|
||||
<MarkedProvider>
|
||||
<DiffComponentProvider component={Diff}>
|
||||
<CodeComponentProvider component={Code}>
|
||||
<ServerProvider defaultUrl={serverDefaults.url} forceUrl={serverDefaults.forced}>
|
||||
<ServerProvider defaultUrl={defaultServerUrl}>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
|
||||
@@ -46,6 +46,25 @@ export function DialogSelectServer() {
|
||||
|
||||
const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
|
||||
|
||||
const sortedItems = createMemo(() => {
|
||||
const list = items()
|
||||
if (!list.length) return list
|
||||
const active = current()
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerStatus) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff = rank(store.status[a]) - rank(store.status[b])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
async function refreshHealth() {
|
||||
const results: Record<string, ServerStatus> = {}
|
||||
await Promise.all(
|
||||
@@ -63,9 +82,14 @@ export function DialogSelectServer() {
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
function select(value: string) {
|
||||
if (store.status[value]?.healthy === false) return
|
||||
function select(value: string, persist?: boolean) {
|
||||
if (!persist && store.status[value]?.healthy === false) return
|
||||
dialog.close()
|
||||
if (persist) {
|
||||
server.add(value)
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
server.setActive(value)
|
||||
navigate("/")
|
||||
}
|
||||
@@ -87,7 +111,7 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
setStore("url", "")
|
||||
select(value)
|
||||
select(value, true)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -96,7 +120,7 @@ export function DialogSelectServer() {
|
||||
<List
|
||||
search={{ placeholder: "Search servers", autofocus: true }}
|
||||
emptyMessage="No servers yet"
|
||||
items={items}
|
||||
items={sortedItems}
|
||||
key={(x) => x}
|
||||
current={current()}
|
||||
onSelect={(x) => {
|
||||
|
||||
@@ -1115,11 +1115,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setStore("imageAttachments", [])
|
||||
setStore("mode", "normal")
|
||||
|
||||
const model = {
|
||||
modelID: local.model.current()!.id,
|
||||
providerID: local.model.current()!.provider.id,
|
||||
const currentModel = local.model.current()
|
||||
const currentAgent = local.agent.current()
|
||||
if (!currentModel || !currentAgent) {
|
||||
console.warn("No agent or model available for prompt submission")
|
||||
return
|
||||
}
|
||||
const agent = local.agent.current()!.name
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const variant = local.model.variant.current()
|
||||
|
||||
if (isShellMode) {
|
||||
sdk.client.session
|
||||
@@ -1147,6 +1154,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: `${model.providerID}/${model.modelID}`,
|
||||
variant,
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to send command", e)
|
||||
@@ -1183,6 +1191,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
model,
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
variant,
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to send prompt", e)
|
||||
@@ -1321,6 +1330,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"[&_[data-type=file]]:text-icon-info-active": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
@@ -1359,7 +1369,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
>
|
||||
<Select
|
||||
options={local.agent.list().map((agent) => agent.name)}
|
||||
current={local.agent.current().name}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize"
|
||||
variant="ghost"
|
||||
@@ -1368,9 +1378,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Choose model</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Choose model</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
|
||||
</div>
|
||||
<Show when={local.model.current()?.provider.name}>
|
||||
<span class="text-text-weak">{local.model.current()?.provider.name}</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -1384,12 +1399,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<span class="hidden md:block ml-0.5 text-text-weak text-12-regular">
|
||||
{local.model.current()?.provider.name}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Show when={local.model.variant.list().length > 0}>
|
||||
<Tooltip placement="top" value="Cycle effort level">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => local.model.variant.cycle()}
|
||||
classList={{
|
||||
"text-icon-warning": !!local.model.variant.current(),
|
||||
}}
|
||||
>
|
||||
<Icon name="brain" size="small" />
|
||||
<Show when={local.model.variant.current()}>
|
||||
<span class="text-12-regular">{local.model.variant.current()}</span>
|
||||
</Show>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
@@ -233,6 +233,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
style={{ "background-color": terminalColors().background }}
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
"select-text": true,
|
||||
"size-full px-6 py-3 font-mono": true,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
|
||||
@@ -65,23 +65,40 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const agent = (() => {
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||
const [store, setStore] = createStore<{
|
||||
current: string
|
||||
current?: string
|
||||
}>({
|
||||
current: list()[0].name,
|
||||
current: list()[0]?.name,
|
||||
})
|
||||
return {
|
||||
list,
|
||||
current() {
|
||||
return list().find((x) => x.name === store.current)!
|
||||
const available = list()
|
||||
if (available.length === 0) return undefined
|
||||
return available.find((x) => x.name === store.current) ?? available[0]
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
setStore("current", name ?? list()[0].name)
|
||||
const available = list()
|
||||
if (available.length === 0) {
|
||||
setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
if (name && available.some((x) => x.name === name)) {
|
||||
setStore("current", name)
|
||||
return
|
||||
}
|
||||
setStore("current", available[0].name)
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
let next = list().findIndex((x) => x.name === store.current) + direction
|
||||
if (next < 0) next = list().length - 1
|
||||
if (next >= list().length) next = 0
|
||||
const value = list()[next]
|
||||
const available = list()
|
||||
if (available.length === 0) {
|
||||
setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
let next = available.findIndex((x) => x.name === store.current) + direction
|
||||
if (next < 0) next = available.length - 1
|
||||
if (next >= available.length) next = 0
|
||||
const value = available[next]
|
||||
if (!value) return
|
||||
setStore("current", value.name)
|
||||
if (value.model)
|
||||
model.set({
|
||||
@@ -98,9 +115,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
createStore<{
|
||||
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
|
||||
recent: ModelKey[]
|
||||
variant?: Record<string, string | undefined>
|
||||
}>({
|
||||
user: [],
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -182,11 +201,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const current = createMemo(() => {
|
||||
const a = agent.current()
|
||||
if (!a) return undefined
|
||||
const key = getFirstValidModel(
|
||||
() => ephemeral.model[a.name],
|
||||
() => a.model,
|
||||
fallbackModel,
|
||||
)!
|
||||
)
|
||||
if (!key) return undefined
|
||||
return find(key)
|
||||
})
|
||||
|
||||
@@ -232,7 +253,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
cycle,
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setEphemeral("model", agent.current().name, model ?? fallbackModel())
|
||||
const currentAgent = agent.current()
|
||||
if (currentAgent) setEphemeral("model", currentAgent.name, model ?? fallbackModel())
|
||||
if (model) updateVisibility(model, "show")
|
||||
if (options?.recent && model) {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
@@ -252,6 +274,45 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
setVisibility(model: ModelKey, visible: boolean) {
|
||||
updateVisibility(model, visible ? "show" : "hide")
|
||||
},
|
||||
variant: {
|
||||
current() {
|
||||
const m = current()
|
||||
if (!m) return undefined
|
||||
const key = `${m.provider.id}/${m.id}`
|
||||
return store.variant?.[key]
|
||||
},
|
||||
list() {
|
||||
const m = current()
|
||||
if (!m) return []
|
||||
if (!m.variants) return []
|
||||
return Object.keys(m.variants)
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
const m = current()
|
||||
if (!m) return
|
||||
const key = `${m.provider.id}/${m.id}`
|
||||
if (!store.variant) {
|
||||
setStore("variant", { [key]: value })
|
||||
} else {
|
||||
setStore("variant", key, value)
|
||||
}
|
||||
},
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
if (variants.length === 0) return
|
||||
const currentVariant = this.current()
|
||||
if (!currentVariant) {
|
||||
this.set(variants[0])
|
||||
return
|
||||
}
|
||||
const index = variants.indexOf(currentVariant)
|
||||
if (index === -1 || index === variants.length - 1) {
|
||||
this.set(undefined)
|
||||
return
|
||||
}
|
||||
this.set(variants[index + 1])
|
||||
},
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
|
||||
@@ -23,30 +23,49 @@ export function serverDisplayName(url: string) {
|
||||
.split("/")[0]
|
||||
}
|
||||
|
||||
function projectsKey(url: string) {
|
||||
if (!url) return ""
|
||||
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
||||
if (host === "localhost" || host === "127.0.0.1") return "local"
|
||||
return url
|
||||
}
|
||||
|
||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||
name: "Server",
|
||||
init: (props: { defaultUrl: string; forceUrl?: boolean }) => {
|
||||
init: (props: { defaultUrl: string }) => {
|
||||
const platform = usePlatform()
|
||||
const fallback = () => normalizeServerUrl(props.defaultUrl)
|
||||
const [forced, setForced] = createSignal(props.forceUrl ?? false)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
"server.v2",
|
||||
"server.v3",
|
||||
createStore({
|
||||
list: [] as string[],
|
||||
active: "",
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
}),
|
||||
)
|
||||
|
||||
const [active, setActiveRaw] = createSignal("")
|
||||
|
||||
function setActive(input: string) {
|
||||
const url = normalizeServerUrl(input)
|
||||
if (!url) return
|
||||
setActiveRaw(url)
|
||||
}
|
||||
|
||||
function add(input: string) {
|
||||
const url = normalizeServerUrl(input)
|
||||
if (!url) return
|
||||
|
||||
const fallback = normalizeServerUrl(props.defaultUrl)
|
||||
if (fallback && url === fallback) {
|
||||
setActiveRaw(url)
|
||||
return
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
if (!store.list.includes(url)) {
|
||||
setStore("list", (list) => [url, ...list])
|
||||
setStore("list", store.list.length, url)
|
||||
}
|
||||
setStore("active", url)
|
||||
setActiveRaw(url)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,49 +74,26 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
if (!url) return
|
||||
|
||||
const list = store.list.filter((x) => x !== url)
|
||||
const next = store.active === url ? (list[0] ?? fallback() ?? "") : store.active
|
||||
const next = active() === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : active()
|
||||
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
setStore("active", next)
|
||||
setActiveRaw(next)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
|
||||
const url = fallback()
|
||||
if (active()) return
|
||||
const url = normalizeServerUrl(props.defaultUrl)
|
||||
if (!url) return
|
||||
|
||||
if (forced()) {
|
||||
batch(() => {
|
||||
if (!store.list.includes(url)) {
|
||||
setStore("list", (list) => [url, ...list])
|
||||
}
|
||||
if (store.active !== url) {
|
||||
setStore("active", url)
|
||||
}
|
||||
})
|
||||
setForced(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (store.list.length === 0) {
|
||||
batch(() => {
|
||||
setStore("list", [url])
|
||||
setStore("active", url)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (store.active && store.list.includes(store.active)) return
|
||||
setStore("active", store.list[0])
|
||||
setActiveRaw(url)
|
||||
})
|
||||
|
||||
const isReady = createMemo(() => ready() && !!store.active)
|
||||
const isReady = createMemo(() => ready() && !!active())
|
||||
|
||||
const [healthy, { refetch }] = createResource(
|
||||
() => store.active || undefined,
|
||||
() => active() || undefined,
|
||||
async (url) => {
|
||||
if (!url) return
|
||||
|
||||
@@ -114,71 +110,74 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.active) return
|
||||
if (!active()) return
|
||||
const interval = setInterval(() => refetch(), 10_000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
const projectsList = createMemo(() => store.projects[store.active] ?? [])
|
||||
const origin = createMemo(() => projectsKey(active()))
|
||||
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
||||
const isLocal = createMemo(() => origin() === "local")
|
||||
|
||||
return {
|
||||
ready: isReady,
|
||||
healthy,
|
||||
isLocal,
|
||||
get url() {
|
||||
return store.active
|
||||
return active()
|
||||
},
|
||||
get name() {
|
||||
return serverDisplayName(store.active)
|
||||
return serverDisplayName(active())
|
||||
},
|
||||
get list() {
|
||||
return store.list
|
||||
},
|
||||
setActive,
|
||||
add: setActive,
|
||||
add,
|
||||
remove,
|
||||
projects: {
|
||||
list: projectsList,
|
||||
open(directory: string) {
|
||||
const url = store.active
|
||||
if (!url) return
|
||||
const current = store.projects[url] ?? []
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
if (current.find((x) => x.worktree === directory)) return
|
||||
setStore("projects", url, [{ worktree: directory, expanded: true }, ...current])
|
||||
setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
|
||||
},
|
||||
close(directory: string) {
|
||||
const url = store.active
|
||||
if (!url) return
|
||||
const current = store.projects[url] ?? []
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
setStore(
|
||||
"projects",
|
||||
url,
|
||||
key,
|
||||
current.filter((x) => x.worktree !== directory),
|
||||
)
|
||||
},
|
||||
expand(directory: string) {
|
||||
const url = store.active
|
||||
if (!url) return
|
||||
const current = store.projects[url] ?? []
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
const index = current.findIndex((x) => x.worktree === directory)
|
||||
if (index !== -1) setStore("projects", url, index, "expanded", true)
|
||||
if (index !== -1) setStore("projects", key, index, "expanded", true)
|
||||
},
|
||||
collapse(directory: string) {
|
||||
const url = store.active
|
||||
if (!url) return
|
||||
const current = store.projects[url] ?? []
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
const index = current.findIndex((x) => x.worktree === directory)
|
||||
if (index !== -1) setStore("projects", url, index, "expanded", false)
|
||||
if (index !== -1) setStore("projects", key, index, "expanded", false)
|
||||
},
|
||||
move(directory: string, toIndex: number) {
|
||||
const url = store.active
|
||||
if (!url) return
|
||||
const current = store.projects[url] ?? []
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
const current = store.projects[key] ?? []
|
||||
const fromIndex = current.findIndex((x) => x.worktree === directory)
|
||||
if (fromIndex === -1 || fromIndex === toIndex) return
|
||||
const result = [...current]
|
||||
const [item] = result.splice(fromIndex, 1)
|
||||
result.splice(toIndex, 0, item)
|
||||
setStore("projects", url, result)
|
||||
setStore("projects", key, result)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Component, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
|
||||
@@ -181,6 +182,25 @@ interface ErrorPageProps {
|
||||
|
||||
export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
const platform = usePlatform()
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
version: undefined as string | undefined,
|
||||
})
|
||||
|
||||
async function checkForUpdates() {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
const result = await platform.checkUpdate()
|
||||
setStore("checking", false)
|
||||
if (result.updateAvailable && result.version) setStore("version", result.version)
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
if (!platform.update || !platform.restart) return
|
||||
await platform.update()
|
||||
await platform.restart()
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
|
||||
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
|
||||
@@ -198,9 +218,25 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
label="Error Details"
|
||||
hideLabel
|
||||
/>
|
||||
<Button size="large" onClick={platform.restart}>
|
||||
Restart
|
||||
</Button>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button size="large" onClick={platform.restart}>
|
||||
Restart
|
||||
</Button>
|
||||
<Show when={platform.checkUpdate}>
|
||||
<Show
|
||||
when={store.version}
|
||||
fallback={
|
||||
<Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
|
||||
{store.checking ? "Checking..." : "Check for updates"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button size="large" onClick={installUpdate}>
|
||||
Update to {store.version}
|
||||
</Button>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
Please report this error to the OpenCode team
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog) {
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: "Open project",
|
||||
multiple: true,
|
||||
|
||||
@@ -54,6 +54,7 @@ import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { useServer } from "@/context/server"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const [store, setStore] = createStore({
|
||||
@@ -88,6 +89,7 @@ export default function Layout(props: ParentProps) {
|
||||
const globalSync = useGlobalSync()
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const server = useServer()
|
||||
const notification = useNotification()
|
||||
const navigate = useNavigate()
|
||||
const providers = useProviders()
|
||||
@@ -476,7 +478,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog) {
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: "Open project",
|
||||
multiple: true,
|
||||
@@ -1015,7 +1017,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative flex-1 min-h-0 flex flex-col">
|
||||
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
<Header
|
||||
navigateToProject={navigateToProject}
|
||||
navigateToSession={navigateToSession}
|
||||
|
||||
@@ -858,7 +858,7 @@ export default function Page() {
|
||||
</Tabs.List>
|
||||
</div>
|
||||
<Show when={diffs().length}>
|
||||
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionReview
|
||||
classes={{
|
||||
@@ -884,7 +884,7 @@ export default function Page() {
|
||||
},
|
||||
)
|
||||
return (
|
||||
<Tabs.Content value={tab} class="select-text mt-3">
|
||||
<Tabs.Content value={tab} class="mt-3">
|
||||
<Switch>
|
||||
<Match when={file()}>
|
||||
{(f) => (
|
||||
@@ -896,7 +896,7 @@ export default function Page() {
|
||||
cacheKey: checksum(f().content?.content ?? ""),
|
||||
}}
|
||||
overflow="scroll"
|
||||
class="pb-40"
|
||||
class="select-text pb-40"
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -b",
|
||||
|
||||
@@ -107,7 +107,10 @@ const platform: Platform = {
|
||||
|
||||
await Promise.resolve()
|
||||
.then(() => {
|
||||
const notification = new Notification(title, { body: description ?? "" })
|
||||
const notification = new Notification(title, {
|
||||
body: description ?? "",
|
||||
icon: "https://opencode.ai/favicon-96x96.png",
|
||||
})
|
||||
notification.onclick = () => {
|
||||
const win = getCurrentWindow()
|
||||
void win.show().catch(() => undefined)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FileRoutes } from "@solidjs/start/router"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { Suspense } from "solid-js"
|
||||
import "./app.css"
|
||||
import { Favicon } from "@opencode-ai/ui/favicon"
|
||||
@@ -12,11 +13,13 @@ export default function App() {
|
||||
<Router
|
||||
root={(props) => (
|
||||
<MetaProvider>
|
||||
<MarkedProvider>
|
||||
<Favicon />
|
||||
<Font />
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</MarkedProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<Favicon />
|
||||
<Font />
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</MetaProvider>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -162,11 +162,20 @@ export default function () {
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(e) => {
|
||||
fallback={(error) => {
|
||||
if (SessionDataMissingError.isInstance(error)) {
|
||||
return <NotFound />
|
||||
}
|
||||
console.error(error)
|
||||
const details = error instanceof Error ? (error.stack ?? error.message) : String(error)
|
||||
return (
|
||||
<Show when={e.message === "SessionDataMissingError"}>
|
||||
<NotFound />
|
||||
</Show>
|
||||
<div class="min-h-screen w-full bg-background-base text-text-base flex flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<p class="text-16-medium">Unable to render this share.</p>
|
||||
<p class="text-14-regular text-text-weaker">Check the console for more details.</p>
|
||||
<pre class="text-12-mono text-left whitespace-pre-wrap break-words w-full max-w-200 bg-background-stronger rounded-md p-4">
|
||||
{details}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.0.212"
|
||||
version = "1.0.219"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/sst/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.212/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.219/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.212/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.219/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.212/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.219/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.212/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.219/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.212/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.219/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
@@ -66,6 +66,7 @@
|
||||
"@ai-sdk/provider": "2.0.0",
|
||||
"@ai-sdk/provider-utils": "3.0.19",
|
||||
"@ai-sdk/togetherai": "1.0.30",
|
||||
"@ai-sdk/vercel": "1.0.31",
|
||||
"@ai-sdk/xai": "2.0.42",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
@@ -79,8 +80,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@opentui/core": "0.1.63",
|
||||
"@opentui/solid": "0.1.63",
|
||||
"@opentui/core": "0.1.67",
|
||||
"@opentui/solid": "0.1.67",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
||||
@@ -349,6 +349,12 @@ export const AuthLoginCommand = cmd({
|
||||
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
||||
}
|
||||
|
||||
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
|
||||
prompts.log.info(
|
||||
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
|
||||
)
|
||||
}
|
||||
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
|
||||
@@ -20,6 +20,17 @@ interface SessionStats {
|
||||
}
|
||||
}
|
||||
toolUsage: Record<string, number>
|
||||
modelUsage: Record<
|
||||
string,
|
||||
{
|
||||
messages: number
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
}
|
||||
cost: number
|
||||
}
|
||||
>
|
||||
dateRange: {
|
||||
earliest: number
|
||||
latest: number
|
||||
@@ -43,6 +54,9 @@ export const StatsCommand = cmd({
|
||||
describe: "number of tools to show (default: all)",
|
||||
type: "number",
|
||||
})
|
||||
.option("models", {
|
||||
describe: "show model statistics (default: hidden). Pass a number to show top N, otherwise shows all",
|
||||
})
|
||||
.option("project", {
|
||||
describe: "filter by project (default: all projects, empty string: current project)",
|
||||
type: "string",
|
||||
@@ -51,7 +65,15 @@ export const StatsCommand = cmd({
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const stats = await aggregateSessionStats(args.days, args.project)
|
||||
displayStats(stats, args.tools)
|
||||
|
||||
let modelLimit: number | undefined
|
||||
if (args.models === true) {
|
||||
modelLimit = Infinity
|
||||
} else if (typeof args.models === "number") {
|
||||
modelLimit = args.models
|
||||
}
|
||||
|
||||
displayStats(stats, args.tools, modelLimit)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -121,6 +143,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
||||
},
|
||||
},
|
||||
toolUsage: {},
|
||||
modelUsage: {},
|
||||
dateRange: {
|
||||
earliest: Date.now(),
|
||||
latest: Date.now(),
|
||||
@@ -154,17 +177,43 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
||||
let sessionCost = 0
|
||||
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
let sessionToolUsage: Record<string, number> = {}
|
||||
let sessionModelUsage: Record<
|
||||
string,
|
||||
{
|
||||
messages: number
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
}
|
||||
cost: number
|
||||
}
|
||||
> = {}
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.info.role === "assistant") {
|
||||
sessionCost += message.info.cost || 0
|
||||
|
||||
const modelKey = `${message.info.providerID}/${message.info.modelID}`
|
||||
if (!sessionModelUsage[modelKey]) {
|
||||
sessionModelUsage[modelKey] = {
|
||||
messages: 0,
|
||||
tokens: { input: 0, output: 0 },
|
||||
cost: 0,
|
||||
}
|
||||
}
|
||||
sessionModelUsage[modelKey].messages++
|
||||
sessionModelUsage[modelKey].cost += message.info.cost || 0
|
||||
|
||||
if (message.info.tokens) {
|
||||
sessionTokens.input += message.info.tokens.input || 0
|
||||
sessionTokens.output += message.info.tokens.output || 0
|
||||
sessionTokens.reasoning += message.info.tokens.reasoning || 0
|
||||
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
|
||||
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
|
||||
|
||||
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
|
||||
sessionModelUsage[modelKey].tokens.output +=
|
||||
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +230,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
||||
sessionTokens,
|
||||
sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning,
|
||||
sessionToolUsage,
|
||||
sessionModelUsage,
|
||||
earliestTime: session.time.created,
|
||||
latestTime: session.time.updated,
|
||||
}
|
||||
@@ -204,6 +254,20 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
||||
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
|
||||
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
|
||||
}
|
||||
|
||||
for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
|
||||
if (!stats.modelUsage[model]) {
|
||||
stats.modelUsage[model] = {
|
||||
messages: 0,
|
||||
tokens: { input: 0, output: 0 },
|
||||
cost: 0,
|
||||
}
|
||||
}
|
||||
stats.modelUsage[model].messages += usage.messages
|
||||
stats.modelUsage[model].tokens.input += usage.tokens.input
|
||||
stats.modelUsage[model].tokens.output += usage.tokens.output
|
||||
stats.modelUsage[model].cost += usage.cost
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +292,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
||||
return stats
|
||||
}
|
||||
|
||||
export function displayStats(stats: SessionStats, toolLimit?: number) {
|
||||
export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
|
||||
const width = 56
|
||||
|
||||
function renderRow(label: string, value: string): string {
|
||||
@@ -267,6 +331,29 @@ export function displayStats(stats: SessionStats, toolLimit?: number) {
|
||||
console.log("└────────────────────────────────────────────────────────┘")
|
||||
console.log()
|
||||
|
||||
// Model Usage section
|
||||
if (modelLimit !== undefined && Object.keys(stats.modelUsage).length > 0) {
|
||||
const sortedModels = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.messages - a.messages)
|
||||
const modelsToDisplay = modelLimit === Infinity ? sortedModels : sortedModels.slice(0, modelLimit)
|
||||
|
||||
console.log("┌────────────────────────────────────────────────────────┐")
|
||||
console.log("│ MODEL USAGE │")
|
||||
console.log("├────────────────────────────────────────────────────────┤")
|
||||
|
||||
for (const [model, usage] of modelsToDisplay) {
|
||||
console.log(`│ ${model.padEnd(54)} │`)
|
||||
console.log(renderRow(" Messages", usage.messages.toLocaleString()))
|
||||
console.log(renderRow(" Input Tokens", formatNumber(usage.tokens.input)))
|
||||
console.log(renderRow(" Output Tokens", formatNumber(usage.tokens.output)))
|
||||
console.log(renderRow(" Cost", `$${usage.cost.toFixed(4)}`))
|
||||
console.log("├────────────────────────────────────────────────────────┤")
|
||||
}
|
||||
// Remove last separator and add bottom border
|
||||
process.stdout.write("\x1B[1A") // Move up one line
|
||||
console.log("└────────────────────────────────────────────────────────┘")
|
||||
}
|
||||
console.log()
|
||||
|
||||
// Tool Usage section
|
||||
if (Object.keys(stats.toolUsage).length > 0) {
|
||||
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
|
||||
|
||||
@@ -372,6 +372,15 @@ function App() {
|
||||
local.agent.move(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Variant cycle",
|
||||
value: "variant.cycle",
|
||||
keybind: "variant_cycle",
|
||||
category: "Agent",
|
||||
onSelect: () => {
|
||||
local.model.variant.cycle()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Agent cycle reverse",
|
||||
value: "agent.cycle.reverse",
|
||||
|
||||
@@ -877,12 +877,6 @@ export function Prompt(props: PromptProps) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (keybind.match("variant_cycle", e)) {
|
||||
e.preventDefault()
|
||||
if (local.model.variant.list().length === 0) return
|
||||
local.model.variant.cycle()
|
||||
return
|
||||
}
|
||||
if (store.mode === "normal") autocomplete.onKeyDown(e)
|
||||
if (!autocomplete.visible) {
|
||||
if (
|
||||
|
||||
@@ -253,7 +253,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const next = favorites[index]
|
||||
if (!next) return
|
||||
setModelStore("model", agent.current().name, { ...next })
|
||||
const uniq = uniqueBy([next, ...modelStore.recent], (x) => x.providerID + x.modelID)
|
||||
const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
|
||||
if (uniq.length > 10) uniq.pop()
|
||||
setModelStore(
|
||||
"recent",
|
||||
@@ -273,7 +273,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
setModelStore("model", agent.current().name, model)
|
||||
if (options?.recent) {
|
||||
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
|
||||
const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
|
||||
if (uniq.length > 10) uniq.pop()
|
||||
setModelStore(
|
||||
"recent",
|
||||
@@ -319,9 +319,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const provider = sync.data.provider.find((x) => x.id === m.providerID)
|
||||
const info = provider?.models[m.modelID]
|
||||
if (!info?.variants) return []
|
||||
return Object.entries(info.variants)
|
||||
.filter(([_, v]) => !v.disabled)
|
||||
.map(([name]) => name)
|
||||
return Object.keys(info.variants)
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
const m = currentModel()
|
||||
|
||||
@@ -1716,8 +1716,8 @@ ToolRegistry.register<typeof TaskTool>({
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("session_child_cycle")}, {keybind.print("session_child_cycle_reverse")}
|
||||
<span style={{ fg: theme.textMuted }}> to navigate between subagent sessions</span>
|
||||
{keybind.print("session_child_cycle")}
|
||||
<span style={{ fg: theme.textMuted }}> view subagents</span>
|
||||
</text>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -115,11 +115,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
setStore("selected", currentIndex)
|
||||
}
|
||||
}
|
||||
scroll.scrollTo(0)
|
||||
scroll?.scrollTo(0)
|
||||
}),
|
||||
)
|
||||
|
||||
function move(direction: number) {
|
||||
if (flat().length === 0) return
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = flat().length - 1
|
||||
if (next >= flat().length) next = 0
|
||||
@@ -129,6 +130,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
function moveTo(next: number) {
|
||||
setStore("selected", next)
|
||||
props.onMove?.(selected()!)
|
||||
if (!scroll) return
|
||||
const target = scroll.getChildren().find((child) => {
|
||||
return child.id === JSON.stringify(selected()?.value)
|
||||
})
|
||||
@@ -172,7 +174,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
}
|
||||
})
|
||||
|
||||
let scroll: ScrollBoxRenderable
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
const ref: DialogSelectRef<T> = {
|
||||
get filter() {
|
||||
return store.filter
|
||||
@@ -213,61 +215,70 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||
maxHeight={height()}
|
||||
<Show
|
||||
when={grouped().length > 0}
|
||||
fallback={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.textMuted}>No results found</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={grouped()}>
|
||||
{([category, options], index) => (
|
||||
<>
|
||||
<Show when={category}>
|
||||
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
|
||||
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
|
||||
{category}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<For each={options}>
|
||||
{(option) => {
|
||||
const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
|
||||
const current = createMemo(() => isDeepEqual(option.value, props.current))
|
||||
return (
|
||||
<box
|
||||
id={JSON.stringify(option.value)}
|
||||
flexDirection="row"
|
||||
onMouseUp={() => {
|
||||
option.onSelect?.(dialog)
|
||||
props.onSelect?.(option)
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value))
|
||||
if (index === -1) return
|
||||
moveTo(index)
|
||||
}}
|
||||
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
|
||||
paddingLeft={current() || option.gutter ? 1 : 3}
|
||||
paddingRight={3}
|
||||
gap={1}
|
||||
>
|
||||
<Option
|
||||
title={option.title}
|
||||
footer={option.footer}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
active={active()}
|
||||
current={current()}
|
||||
gutter={option.gutter}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<scrollbox
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||
maxHeight={height()}
|
||||
>
|
||||
<For each={grouped()}>
|
||||
{([category, options], index) => (
|
||||
<>
|
||||
<Show when={category}>
|
||||
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
|
||||
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
|
||||
{category}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<For each={options}>
|
||||
{(option) => {
|
||||
const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
|
||||
const current = createMemo(() => isDeepEqual(option.value, props.current))
|
||||
return (
|
||||
<box
|
||||
id={JSON.stringify(option.value)}
|
||||
flexDirection="row"
|
||||
onMouseUp={() => {
|
||||
option.onSelect?.(dialog)
|
||||
props.onSelect?.(option)
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value))
|
||||
if (index === -1) return
|
||||
moveTo(index)
|
||||
}}
|
||||
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
|
||||
paddingLeft={current() || option.gutter ? 1 : 3}
|
||||
paddingRight={3}
|
||||
gap={1}
|
||||
>
|
||||
<Option
|
||||
title={option.title}
|
||||
footer={option.footer}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
active={active()}
|
||||
current={current()}
|
||||
gutter={option.gutter}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
<Show when={keybinds().length} fallback={<box flexShrink={0} />}>
|
||||
<box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
|
||||
<For each={keybinds()}>
|
||||
|
||||
@@ -17,6 +17,12 @@ const options = {
|
||||
describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
|
||||
default: false,
|
||||
},
|
||||
cors: {
|
||||
type: "string" as const,
|
||||
array: true,
|
||||
describe: "additional domains to allow for CORS",
|
||||
default: [] as string[],
|
||||
},
|
||||
}
|
||||
|
||||
export type NetworkOptions = InferredOptionTypes<typeof options>
|
||||
@@ -30,6 +36,7 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
|
||||
const portExplicitlySet = process.argv.includes("--port")
|
||||
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
||||
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
||||
const corsExplicitlySet = process.argv.includes("--cors")
|
||||
|
||||
const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
|
||||
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
|
||||
@@ -38,6 +45,9 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
|
||||
: mdns && !config?.server?.hostname
|
||||
? "0.0.0.0"
|
||||
: (config?.server?.hostname ?? args.hostname)
|
||||
const configCors = config?.server?.cors ?? []
|
||||
const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
|
||||
const cors = [...configCors, ...argsCors]
|
||||
|
||||
return { hostname, port, mdns }
|
||||
return { hostname, port, mdns, cors }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Instance } from "../project/instance"
|
||||
import { Identifier } from "../id/id"
|
||||
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
||||
import PROMPT_REVIEW from "./template/review.txt"
|
||||
import { MCP } from "../mcp"
|
||||
|
||||
export namespace Command {
|
||||
export const Event = {
|
||||
@@ -26,13 +27,28 @@ export namespace Command {
|
||||
description: z.string().optional(),
|
||||
agent: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
template: z.string(),
|
||||
// workaround for zod not supporting async functions natively so we use getters
|
||||
// https://zod.dev/v4/changelog?id=zfunction
|
||||
template: z.promise(z.string()).or(z.string()),
|
||||
subtask: z.boolean().optional(),
|
||||
hints: z.array(z.string()),
|
||||
})
|
||||
.meta({
|
||||
ref: "Command",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
||||
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
|
||||
|
||||
export function hints(template: string): string[] {
|
||||
const result: string[] = []
|
||||
const numbered = template.match(/\$\d+/g)
|
||||
if (numbered) {
|
||||
for (const match of [...new Set(numbered)].sort()) result.push(match)
|
||||
}
|
||||
if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS")
|
||||
return result
|
||||
}
|
||||
|
||||
export const Default = {
|
||||
INIT: "init",
|
||||
@@ -46,13 +62,19 @@ export namespace Command {
|
||||
[Default.INIT]: {
|
||||
name: Default.INIT,
|
||||
description: "create/update AGENTS.md",
|
||||
template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
|
||||
get template() {
|
||||
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
|
||||
},
|
||||
hints: hints(PROMPT_INITIALIZE),
|
||||
},
|
||||
[Default.REVIEW]: {
|
||||
name: Default.REVIEW,
|
||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||
template: PROMPT_REVIEW.replace("${path}", Instance.worktree),
|
||||
get template() {
|
||||
return PROMPT_REVIEW.replace("${path}", Instance.worktree)
|
||||
},
|
||||
subtask: true,
|
||||
hints: hints(PROMPT_REVIEW),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -62,8 +84,36 @@ export namespace Command {
|
||||
agent: command.agent,
|
||||
model: command.model,
|
||||
description: command.description,
|
||||
template: command.template,
|
||||
get template() {
|
||||
return command.template
|
||||
},
|
||||
subtask: command.subtask,
|
||||
hints: hints(command.template),
|
||||
}
|
||||
}
|
||||
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
|
||||
result[name] = {
|
||||
name,
|
||||
description: prompt.description,
|
||||
get template() {
|
||||
// since a getter can't be async we need to manually return a promise here
|
||||
return new Promise<string>(async (resolve, reject) => {
|
||||
const template = await MCP.getPrompt(
|
||||
prompt.client,
|
||||
prompt.name,
|
||||
prompt.arguments
|
||||
? // substitute each argument with $1, $2, etc.
|
||||
Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
|
||||
: {},
|
||||
).catch(reject)
|
||||
resolve(
|
||||
template?.messages
|
||||
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
||||
.join("\n") || "",
|
||||
)
|
||||
})
|
||||
},
|
||||
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,8 +92,6 @@ export namespace Config {
|
||||
|
||||
const promises: Promise<void>[] = []
|
||||
for (const dir of unique(directories)) {
|
||||
await assertValid(dir)
|
||||
|
||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||
log.debug(`loading config from ${path.join(dir, file)}`)
|
||||
@@ -155,23 +153,6 @@ export namespace Config {
|
||||
}
|
||||
})
|
||||
|
||||
const INVALID_DIRS = new Bun.Glob(`{${["agents", "commands", "plugins", "tools", "skills"].join(",")}}/`)
|
||||
async function assertValid(dir: string) {
|
||||
const invalid = await Array.fromAsync(
|
||||
INVALID_DIRS.scan({
|
||||
onlyFiles: false,
|
||||
cwd: dir,
|
||||
}),
|
||||
)
|
||||
for (const item of invalid) {
|
||||
throw new ConfigDirectoryTypoError({
|
||||
path: dir,
|
||||
dir: item,
|
||||
suggestion: item.substring(0, item.length - 1),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function installDependencies(dir: string) {
|
||||
if (Installation.isLocal()) return
|
||||
|
||||
@@ -197,7 +178,7 @@ export namespace Config {
|
||||
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
|
||||
}
|
||||
|
||||
const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
|
||||
const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md")
|
||||
async function loadCommand(dir: string) {
|
||||
const result: Record<string, Command> = {}
|
||||
for await (const item of COMMAND_GLOB.scan({
|
||||
@@ -235,7 +216,7 @@ export namespace Config {
|
||||
return result
|
||||
}
|
||||
|
||||
const AGENT_GLOB = new Bun.Glob("agent/**/*.md")
|
||||
const AGENT_GLOB = new Bun.Glob("{agent,agents}/**/*.md")
|
||||
async function loadAgent(dir: string) {
|
||||
const result: Record<string, Agent> = {}
|
||||
|
||||
@@ -278,7 +259,7 @@ export namespace Config {
|
||||
return result
|
||||
}
|
||||
|
||||
const MODE_GLOB = new Bun.Glob("mode/*.md")
|
||||
const MODE_GLOB = new Bun.Glob("{mode,modes}/**/*.md")
|
||||
async function loadMode(dir: string) {
|
||||
const result: Record<string, Agent> = {}
|
||||
for await (const item of MODE_GLOB.scan({
|
||||
@@ -307,7 +288,7 @@ export namespace Config {
|
||||
return result
|
||||
}
|
||||
|
||||
const PLUGIN_GLOB = new Bun.Glob("plugin/*.{ts,js}")
|
||||
const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/**/*.{ts,js}")
|
||||
async function loadPlugin(dir: string) {
|
||||
const plugins: string[] = []
|
||||
|
||||
@@ -605,6 +586,7 @@ export namespace Config {
|
||||
port: z.number().int().positive().optional().describe("Port to listen on"),
|
||||
hostname: z.string().optional().describe("Hostname to listen on"),
|
||||
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
|
||||
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
@@ -620,7 +602,24 @@ export namespace Config {
|
||||
.extend({
|
||||
whitelist: z.array(z.string()).optional(),
|
||||
blacklist: z.array(z.string()).optional(),
|
||||
models: z.record(z.string(), ModelsDev.Model.partial()).optional(),
|
||||
models: z
|
||||
.record(
|
||||
z.string(),
|
||||
ModelsDev.Model.partial().extend({
|
||||
variants: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
disabled: z.boolean().optional().describe("Disable this variant for the model"),
|
||||
})
|
||||
.catchall(z.any()),
|
||||
)
|
||||
.optional()
|
||||
.describe("Variant-specific configuration"),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
options: z
|
||||
.object({
|
||||
apiKey: z.string().optional(),
|
||||
@@ -846,6 +845,12 @@ export namespace Config {
|
||||
.optional()
|
||||
.describe("Tools that should only be available to primary agents."),
|
||||
continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"),
|
||||
mcp_timeout: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
@@ -331,3 +331,12 @@ export const nixfmt: Info = {
|
||||
return Bun.which("nixfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const rustfmt: Info = {
|
||||
name: "rustfmt",
|
||||
command: ["rustfmt", "$FILE"],
|
||||
extensions: [".rs"],
|
||||
async enabled() {
|
||||
return Bun.which("rustfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1437,6 +1437,24 @@ export namespace LSPServer {
|
||||
},
|
||||
}
|
||||
|
||||
export const Prisma: Info = {
|
||||
id: "prisma",
|
||||
extensions: [".prisma"],
|
||||
root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]),
|
||||
async spawn(root) {
|
||||
const prisma = Bun.which("prisma")
|
||||
if (!prisma) {
|
||||
log.info("prisma not found, please install prisma")
|
||||
return
|
||||
}
|
||||
return {
|
||||
process: spawn(prisma, ["language-server"], {
|
||||
cwd: root,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const Dart: Info = {
|
||||
id: "dart",
|
||||
extensions: [".dart"],
|
||||
|
||||
@@ -4,7 +4,11 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { type Tool as MCPToolDef, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
type Tool as MCPToolDef,
|
||||
ToolListChangedNotificationSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Config } from "../config/config"
|
||||
import { Log } from "../util/log"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
@@ -93,7 +97,7 @@ export namespace MCP {
|
||||
}
|
||||
|
||||
// Convert MCP tool definition to AI SDK Tool type
|
||||
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Tool {
|
||||
async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Promise<Tool> {
|
||||
const inputSchema = mcpTool.inputSchema
|
||||
|
||||
// Spread first, then override type to ensure it's always "object"
|
||||
@@ -103,15 +107,23 @@ export namespace MCP {
|
||||
properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
|
||||
additionalProperties: false,
|
||||
}
|
||||
const config = await Config.get()
|
||||
|
||||
return dynamicTool({
|
||||
description: mcpTool.description ?? "",
|
||||
inputSchema: jsonSchema(schema),
|
||||
execute: async (args: unknown) => {
|
||||
return client.callTool({
|
||||
name: mcpTool.name,
|
||||
arguments: args as Record<string, unknown>,
|
||||
})
|
||||
return client.callTool(
|
||||
{
|
||||
name: mcpTool.name,
|
||||
arguments: args as Record<string, unknown>,
|
||||
},
|
||||
CallToolResultSchema,
|
||||
{
|
||||
resetTimeoutOnProgress: true,
|
||||
timeout: config.experimental?.mcp_timeout,
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -120,6 +132,9 @@ export namespace MCP {
|
||||
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
|
||||
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
|
||||
|
||||
// Prompt cache types
|
||||
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
|
||||
|
||||
const state = Instance.state(
|
||||
async () => {
|
||||
const cfg = await Config.get()
|
||||
@@ -164,6 +179,29 @@ export namespace MCP {
|
||||
},
|
||||
)
|
||||
|
||||
// Helper function to fetch prompts for a specific client
|
||||
async function fetchPromptsForClient(clientName: string, client: Client) {
|
||||
const prompts = await client.listPrompts().catch((e) => {
|
||||
log.error("failed to get prompts", { clientName, error: e.message })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!prompts) {
|
||||
return
|
||||
}
|
||||
|
||||
const commands: Record<string, PromptInfo & { client: string }> = {}
|
||||
|
||||
for (const prompt of prompts.prompts) {
|
||||
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
const sanitizedPromptName = prompt.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
const key = sanitizedClientName + ":" + sanitizedPromptName + " (MCP)"
|
||||
|
||||
commands[key] = { ...prompt, client: clientName }
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
export async function add(name: string, mcp: Config.Mcp) {
|
||||
const s = await state()
|
||||
const result = await create(name, mcp)
|
||||
@@ -474,12 +512,61 @@ export namespace MCP {
|
||||
for (const mcpTool of toolsResult.tools) {
|
||||
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
result[sanitizedClientName + "_" + sanitizedToolName] = convertMcpTool(mcpTool, client)
|
||||
result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function prompts() {
|
||||
const s = await state()
|
||||
const clientsSnapshot = await clients()
|
||||
|
||||
const prompts = Object.fromEntries<PromptInfo & { client: string }>(
|
||||
(
|
||||
await Promise.all(
|
||||
Object.entries(clientsSnapshot).map(async ([clientName, client]) => {
|
||||
if (s.status[clientName]?.status !== "connected") {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.entries((await fetchPromptsForClient(clientName, client)) ?? {})
|
||||
}),
|
||||
)
|
||||
).flat(),
|
||||
)
|
||||
|
||||
return prompts
|
||||
}
|
||||
|
||||
export async function getPrompt(clientName: string, name: string, args?: Record<string, string>) {
|
||||
const clientsSnapshot = await clients()
|
||||
const client = clientsSnapshot[clientName]
|
||||
|
||||
if (!client) {
|
||||
log.warn("client not found for prompt", {
|
||||
clientName,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
const result = await client
|
||||
.getPrompt({
|
||||
name: name,
|
||||
arguments: args,
|
||||
})
|
||||
.catch((e) => {
|
||||
log.error("failed to get prompt from MCP server", {
|
||||
clientName,
|
||||
promptName: name,
|
||||
error: e.message,
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth authentication flow for an MCP server.
|
||||
* Returns the authorization URL that should be opened in a browser.
|
||||
|
||||
@@ -60,6 +60,7 @@ export namespace ModelsDev {
|
||||
options: z.record(z.string(), z.any()),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
provider: z.object({ npm: z.string() }).optional(),
|
||||
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
|
||||
})
|
||||
export type Model = z.infer<typeof Model>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import z from "zod"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { Config } from "../config/config"
|
||||
import { mapValues, mergeDeep, sortBy } from "remeda"
|
||||
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
|
||||
import { NoSuchModelError, type Provider as SDK } from "ai"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
@@ -34,6 +34,7 @@ import { createCohere } from "@ai-sdk/cohere"
|
||||
import { createGateway } from "@ai-sdk/gateway"
|
||||
import { createTogetherAI } from "@ai-sdk/togetherai"
|
||||
import { createPerplexity } from "@ai-sdk/perplexity"
|
||||
import { createVercel } from "@ai-sdk/vercel"
|
||||
import { ProviderTransform } from "./transform"
|
||||
|
||||
export namespace Provider {
|
||||
@@ -58,6 +59,7 @@ export namespace Provider {
|
||||
"@ai-sdk/gateway": createGateway,
|
||||
"@ai-sdk/togetherai": createTogetherAI,
|
||||
"@ai-sdk/perplexity": createPerplexity,
|
||||
"@ai-sdk/vercel": createVercel,
|
||||
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
|
||||
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
|
||||
}
|
||||
@@ -405,16 +407,6 @@ export namespace Provider {
|
||||
},
|
||||
}
|
||||
|
||||
export const Variant = z
|
||||
.object({
|
||||
disabled: z.boolean(),
|
||||
})
|
||||
.catchall(z.any())
|
||||
.meta({
|
||||
ref: "Variant",
|
||||
})
|
||||
export type Variant = z.infer<typeof Variant>
|
||||
|
||||
export const Model = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -478,7 +470,7 @@ export namespace Provider {
|
||||
options: z.record(z.string(), z.any()),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
release_date: z.string(),
|
||||
variants: z.record(z.string(), Variant).optional(),
|
||||
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "Model",
|
||||
@@ -561,7 +553,7 @@ export namespace Provider {
|
||||
variants: {},
|
||||
}
|
||||
|
||||
m.variants = mapValues(ProviderTransform.variants(m), (v) => ({ disabled: false, ...v }))
|
||||
m.variants = mapValues(ProviderTransform.variants(m), (v) => v)
|
||||
|
||||
return m
|
||||
}
|
||||
@@ -697,7 +689,13 @@ export namespace Provider {
|
||||
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
|
||||
family: model.family ?? existingModel?.family ?? "",
|
||||
release_date: model.release_date ?? existingModel?.release_date ?? "",
|
||||
variants: {},
|
||||
}
|
||||
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
|
||||
parsedModel.variants = mapValues(
|
||||
pickBy(merged, (v) => !v.disabled),
|
||||
(v) => omit(v, ["disabled"]),
|
||||
)
|
||||
parsed.models[modelID] = parsedModel
|
||||
}
|
||||
database[providerID] = parsed
|
||||
@@ -822,6 +820,16 @@ export namespace Provider {
|
||||
(configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
|
||||
)
|
||||
delete provider.models[modelID]
|
||||
|
||||
// Filter out disabled variants from config
|
||||
const configVariants = configProvider?.models?.[modelID]?.variants
|
||||
if (configVariants && model.variants) {
|
||||
const merged = mergeDeep(model.variants, configVariants)
|
||||
model.variants = mapValues(
|
||||
pickBy(merged, (v) => !v.disabled),
|
||||
(v) => omit(v, ["disabled"]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(provider.models).length === 0) {
|
||||
@@ -1010,6 +1018,7 @@ export namespace Provider {
|
||||
"claude-haiku-4.5",
|
||||
"3-5-haiku",
|
||||
"3.5-haiku",
|
||||
"gemini-3-flash",
|
||||
"gemini-2.5-flash",
|
||||
"gpt-5-nano",
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@ import { unique } from "remeda"
|
||||
import type { JSONSchema } from "zod/v4/core"
|
||||
import type { Provider } from "./provider"
|
||||
import type { ModelsDev } from "./models"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
|
||||
|
||||
@@ -229,7 +230,6 @@ export namespace ProviderTransform {
|
||||
const id = model.id.toLowerCase()
|
||||
if (id.includes("qwen")) return 1
|
||||
if (id.includes("minimax-m2")) {
|
||||
if (id.includes("m2.1")) return 0.9
|
||||
return 0.95
|
||||
}
|
||||
if (id.includes("gemini")) return 0.95
|
||||
@@ -238,7 +238,10 @@ export namespace ProviderTransform {
|
||||
|
||||
export function topK(model: Provider.Model) {
|
||||
const id = model.id.toLowerCase()
|
||||
if (id.includes("minimax-m2")) return 20
|
||||
if (id.includes("minimax-m2")) {
|
||||
if (id.includes("m2.1")) return 40
|
||||
return 20
|
||||
}
|
||||
if (id.includes("gemini")) return 64
|
||||
return undefined
|
||||
}
|
||||
@@ -246,7 +249,7 @@ export namespace ProviderTransform {
|
||||
const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]
|
||||
const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
|
||||
|
||||
export function variants(model: Provider.Model) {
|
||||
export function variants(model: Provider.Model): Record<string, Record<string, any>> {
|
||||
if (!model.capabilities.reasoning) return {}
|
||||
|
||||
const id = model.id.toLowerCase()
|
||||
@@ -292,13 +295,17 @@ export namespace ProviderTransform {
|
||||
case "@ai-sdk/openai":
|
||||
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai
|
||||
if (id === "gpt-5-pro") return {}
|
||||
const openaiEfforts = ["minimal", ...WIDELY_SUPPORTED_EFFORTS]
|
||||
if (model.release_date >= "2025-11-13") {
|
||||
openaiEfforts.unshift("none")
|
||||
}
|
||||
if (model.release_date >= "2025-12-04") {
|
||||
openaiEfforts.push("xhigh")
|
||||
}
|
||||
const openaiEfforts = iife(() => {
|
||||
if (model.id.includes("codex")) return WIDELY_SUPPORTED_EFFORTS
|
||||
const arr = ["minimal", ...WIDELY_SUPPORTED_EFFORTS]
|
||||
if (model.release_date >= "2025-11-13") {
|
||||
arr.unshift("none")
|
||||
}
|
||||
if (model.release_date >= "2025-12-04") {
|
||||
arr.push("xhigh")
|
||||
}
|
||||
return arr
|
||||
})
|
||||
return Object.fromEntries(
|
||||
openaiEfforts.map((effort) => [
|
||||
effort,
|
||||
|
||||
@@ -45,7 +45,6 @@ import { Snapshot } from "@/snapshot"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { upgradeWebSocket, websocket } from "hono/bun"
|
||||
import type { BunWebSocketData } from "hono/bun"
|
||||
import { errors } from "./error"
|
||||
import { Pty } from "@/pty"
|
||||
import { Installation } from "@/installation"
|
||||
@@ -58,6 +57,7 @@ export namespace Server {
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
let _url: URL | undefined
|
||||
let _corsWhitelist: string[] = []
|
||||
|
||||
export function url(): URL {
|
||||
return _url ?? new URL("http://localhost:4096")
|
||||
@@ -104,7 +104,27 @@ export namespace Server {
|
||||
timer.stop()
|
||||
}
|
||||
})
|
||||
.use(cors())
|
||||
.use(
|
||||
cors({
|
||||
origin(input) {
|
||||
if (!input) return
|
||||
|
||||
if (input.startsWith("http://localhost:")) return input
|
||||
if (input.startsWith("http://127.0.0.1:")) return input
|
||||
if (input === "tauri://localhost" || input === "http://tauri.localhost") return input
|
||||
|
||||
// *.opencode.ai (https only, adjust if needed)
|
||||
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
|
||||
return input
|
||||
}
|
||||
if (_corsWhitelist.includes(input)) {
|
||||
return input
|
||||
}
|
||||
|
||||
return
|
||||
},
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/global/health",
|
||||
describeRoute({
|
||||
@@ -2660,7 +2680,9 @@ export namespace Server {
|
||||
return result
|
||||
}
|
||||
|
||||
export function listen(opts: { port: number; hostname: string; mdns?: boolean }) {
|
||||
export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
|
||||
_corsWhitelist = opts.cors ?? []
|
||||
|
||||
const args = {
|
||||
hostname: opts.hostname,
|
||||
idleTimeout: 0,
|
||||
@@ -2686,7 +2708,7 @@ export namespace Server {
|
||||
opts.hostname !== "localhost" &&
|
||||
opts.hostname !== "::1"
|
||||
if (shouldPublishMDNS) {
|
||||
MDNS.publish(server.port!)
|
||||
MDNS.publish(server.port!, `opencode-${server.port!}`)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Log } from "@/util/log"
|
||||
import { streamText, wrapLanguageModel, type ModelMessage, type StreamTextResult, type Tool, type ToolSet } from "ai"
|
||||
import {
|
||||
streamText,
|
||||
wrapLanguageModel,
|
||||
type ModelMessage,
|
||||
type StreamTextResult,
|
||||
type Tool,
|
||||
type ToolSet,
|
||||
extractReasoningMiddleware,
|
||||
} from "ai"
|
||||
import { clone, mergeDeep, pipe } from "remeda"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -74,13 +82,14 @@ export namespace LLM {
|
||||
}
|
||||
|
||||
const provider = await Provider.getProvider(input.model.providerID)
|
||||
const variant = input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : undefined
|
||||
const small = input.small ? ProviderTransform.smallOptions(input.model) : {}
|
||||
const variant = input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
|
||||
const options = pipe(
|
||||
ProviderTransform.options(input.model, input.sessionID, provider.options),
|
||||
mergeDeep(input.small ? ProviderTransform.smallOptions(input.model) : {}),
|
||||
mergeDeep(small),
|
||||
mergeDeep(input.model.options),
|
||||
mergeDeep(input.agent.options),
|
||||
mergeDeep(variant && !variant.disabled ? variant : {}),
|
||||
mergeDeep(variant),
|
||||
)
|
||||
|
||||
const params = await Plugin.trigger(
|
||||
@@ -183,6 +192,7 @@ export namespace LLM {
|
||||
return args.params
|
||||
},
|
||||
},
|
||||
extractReasoningMiddleware({ tagName: "think", startWithReasoning: false }),
|
||||
],
|
||||
}),
|
||||
experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry },
|
||||
|
||||
@@ -1290,7 +1290,9 @@ export namespace SessionPrompt {
|
||||
const raw = input.arguments.match(argsRegex) ?? []
|
||||
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
|
||||
|
||||
const placeholders = command.template.match(placeholderRegex) ?? []
|
||||
const templateCommand = await command.template
|
||||
|
||||
const placeholders = templateCommand.match(placeholderRegex) ?? []
|
||||
let last = 0
|
||||
for (const item of placeholders) {
|
||||
const value = Number(item.slice(1))
|
||||
@@ -1298,7 +1300,7 @@ export namespace SessionPrompt {
|
||||
}
|
||||
|
||||
// Let the final placeholder swallow any extra arguments so prompts read naturally
|
||||
const withArgs = command.template.replaceAll(placeholderRegex, (_, index) => {
|
||||
const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => {
|
||||
const position = Number(index)
|
||||
const argIndex = position - 1
|
||||
if (argIndex >= args.length) return ""
|
||||
|
||||
@@ -240,7 +240,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
|
||||
- Choose descriptive names that fit the content
|
||||
- Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**`
|
||||
- Leave no blank line before the first bullet under a header.
|
||||
- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer.
|
||||
- Section headers should only be used where they genuinely improve scannability; avoid fragmenting the answer.
|
||||
|
||||
**Bullets**
|
||||
|
||||
@@ -289,7 +289,7 @@ When referencing files in your response, make sure to include the relevant start
|
||||
- Don’t nest bullets or create deep hierarchies.
|
||||
- Don’t output ANSI escape codes directly — the CLI renderer applies them.
|
||||
- Don’t cram unrelated keywords into a single bullet; split for clarity.
|
||||
- Don’t let keyword lists run long — wrap or reformat for scanability.
|
||||
- Don’t let keyword lists run long — wrap or reformat for scannability.
|
||||
|
||||
Generally, ensure your final answers adapt their shape and depth to the request. For example, answers to code explanations should have a precise, structured explanation with code references that answer the question directly. For tasks with a simple implementation, lead with the outcome and supplement only with what’s needed for clarity. Larger changes can be presented as a logical walkthrough of your approach, grouping related steps, explaining rationale where it adds value, and highlighting next actions to accelerate the user. Your answers should provide the right level of detail while being easily scannable.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ is a critical violation. ZERO exceptions.
|
||||
|
||||
## Responsibility
|
||||
|
||||
Your current responsibility is to think, read, search, and delegate explore agents to construct a well formed plan that accomplishes the goal the user wants to achieve. Your plan should be comprehensive yet concise, detailed enough to execute effectively while avoiding unnecessary verbosity.
|
||||
Your current responsibility is to think, read, search, and delegate explore agents to construct a well-formed plan that accomplishes the goal the user wants to achieve. Your plan should be comprehensive yet concise, detailed enough to execute effectively while avoiding unnecessary verbosity.
|
||||
|
||||
Ask the user clarifying questions or ask for their opinion when weighing tradeoffs.
|
||||
|
||||
@@ -22,5 +22,5 @@ Ask the user clarifying questions or ask for their opinion when weighing tradeof
|
||||
|
||||
## Important
|
||||
|
||||
The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received.
|
||||
The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
|
||||
</system-reminder>
|
||||
|
||||
@@ -35,7 +35,7 @@ export namespace Skill {
|
||||
}),
|
||||
)
|
||||
|
||||
const OPENCODE_SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")
|
||||
const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
|
||||
const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
|
||||
|
||||
export const state = Instance.state(async () => {
|
||||
|
||||
@@ -280,7 +280,7 @@ export const BashTool = Tool.define("bash", async () => {
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
resultMetadata.push(`bash tool terminated commmand after exceeding timeout ${timeout} ms`)
|
||||
resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`)
|
||||
}
|
||||
|
||||
if (aborted) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Launch a new agent to handle complex, multi-step tasks autonomously.
|
||||
Launch a new agent to handle complex, multistep tasks autonomously.
|
||||
|
||||
Available agent types and the tools they have access to:
|
||||
{agents}
|
||||
|
||||
@@ -4,7 +4,7 @@ It also helps the user understand the progress of the task and overall progress
|
||||
## When to Use This Tool
|
||||
Use this tool proactively in these scenarios:
|
||||
|
||||
1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions
|
||||
1. Complex multistep tasks - When a task requires 3 or more distinct steps or actions
|
||||
2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations
|
||||
3. User explicitly requests todo list - When the user directly asks you to use the todo list
|
||||
4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)
|
||||
|
||||
@@ -1807,3 +1807,321 @@ test("custom model inherits api.url from models.dev provider", async () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model variants are generated for reasoning models", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
// Claude sonnet 4 has reasoning capability
|
||||
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
|
||||
expect(model.capabilities.reasoning).toBe(true)
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(Object.keys(model.variants!).length).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model variants can be disabled via config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
variants: {
|
||||
high: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(model.variants!["high"]).toBeUndefined()
|
||||
// max variant should still exist
|
||||
expect(model.variants!["max"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model variants can be customized via config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
variants: {
|
||||
high: {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budgetTokens: 20000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
|
||||
expect(model.variants!["high"]).toBeDefined()
|
||||
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("disabled key is stripped from variant config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
variants: {
|
||||
max: {
|
||||
disabled: false,
|
||||
customField: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
|
||||
expect(model.variants!["max"]).toBeDefined()
|
||||
expect(model.variants!["max"].disabled).toBeUndefined()
|
||||
expect(model.variants!["max"].customField).toBe("test")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("all variants can be disabled via config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
variants: {
|
||||
high: { disabled: true },
|
||||
max: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(Object.keys(model.variants!).length).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("variant config merges with generated variants", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
variants: {
|
||||
high: {
|
||||
extraOption: "custom-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
|
||||
expect(model.variants!["high"]).toBeDefined()
|
||||
// Should have both the generated thinking config and the custom option
|
||||
expect(model.variants!["high"].thinking).toBeDefined()
|
||||
expect(model.variants!["high"].extraOption).toBe("custom-value")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("variants filtered in second pass for database models", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
openai: {
|
||||
models: {
|
||||
"gpt-5": {
|
||||
variants: {
|
||||
high: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("OPENAI_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["openai"].models["gpt-5"]
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(model.variants!["high"]).toBeUndefined()
|
||||
// Other variants should still exist
|
||||
expect(model.variants!["medium"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("custom model with variants enabled and disabled", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"custom-reasoning": {
|
||||
name: "Custom Reasoning Provider",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
"reasoning-model": {
|
||||
name: "Reasoning Model",
|
||||
tool_call: true,
|
||||
reasoning: true,
|
||||
limit: { context: 128000, output: 16000 },
|
||||
variants: {
|
||||
low: { reasoningEffort: "low" },
|
||||
medium: { reasoningEffort: "medium" },
|
||||
high: { reasoningEffort: "high", disabled: true },
|
||||
custom: { reasoningEffort: "custom", budgetTokens: 5000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
options: { apiKey: "test-key" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["custom-reasoning"].models["reasoning-model"]
|
||||
expect(model.variants).toBeDefined()
|
||||
// Enabled variants should exist
|
||||
expect(model.variants!["low"]).toBeDefined()
|
||||
expect(model.variants!["low"].reasoningEffort).toBe("low")
|
||||
expect(model.variants!["medium"]).toBeDefined()
|
||||
expect(model.variants!["medium"].reasoningEffort).toBe("medium")
|
||||
expect(model.variants!["custom"]).toBeDefined()
|
||||
expect(model.variants!["custom"].reasoningEffort).toBe("custom")
|
||||
expect(model.variants!["custom"].budgetTokens).toBe(5000)
|
||||
// Disabled variant should not exist
|
||||
expect(model.variants!["high"]).toBeUndefined()
|
||||
// disabled key should be stripped from all variants
|
||||
expect(model.variants!["low"].disabled).toBeUndefined()
|
||||
expect(model.variants!["medium"].disabled).toBeUndefined()
|
||||
expect(model.variants!["custom"].disabled).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -409,3 +409,572 @@ describe("ProviderTransform.message - empty image handling", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProviderTransform.variants", () => {
|
||||
const createMockModel = (overrides: Partial<any> = {}): any => ({
|
||||
id: "test/test-model",
|
||||
providerID: "test",
|
||||
api: {
|
||||
id: "test-model",
|
||||
url: "https://api.test.com",
|
||||
npm: "@ai-sdk/openai",
|
||||
},
|
||||
name: "Test Model",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0.001,
|
||||
output: 0.002,
|
||||
cache: { read: 0.0001, write: 0.0002 },
|
||||
},
|
||||
limit: {
|
||||
context: 128000,
|
||||
output: 8192,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2024-01-01",
|
||||
...overrides,
|
||||
})
|
||||
|
||||
test("returns empty object when model has no reasoning capabilities", () => {
|
||||
const model = createMockModel({
|
||||
capabilities: { reasoning: false },
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("deepseek returns empty object", () => {
|
||||
const model = createMockModel({
|
||||
id: "deepseek/deepseek-chat",
|
||||
providerID: "deepseek",
|
||||
api: {
|
||||
id: "deepseek-chat",
|
||||
url: "https://api.deepseek.com",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("minimax returns empty object", () => {
|
||||
const model = createMockModel({
|
||||
id: "minimax/minimax-model",
|
||||
providerID: "minimax",
|
||||
api: {
|
||||
id: "minimax-model",
|
||||
url: "https://api.minimax.com",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("glm returns empty object", () => {
|
||||
const model = createMockModel({
|
||||
id: "glm/glm-4",
|
||||
providerID: "glm",
|
||||
api: {
|
||||
id: "glm-4",
|
||||
url: "https://api.glm.com",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("mistral returns empty object", () => {
|
||||
const model = createMockModel({
|
||||
id: "mistral/mistral-large",
|
||||
providerID: "mistral",
|
||||
api: {
|
||||
id: "mistral-large-latest",
|
||||
url: "https://api.mistral.com",
|
||||
npm: "@ai-sdk/mistral",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
describe("@openrouter/ai-sdk-provider", () => {
|
||||
test("returns empty object for non-qualifying models", () => {
|
||||
const model = createMockModel({
|
||||
id: "openrouter/test-model",
|
||||
providerID: "openrouter",
|
||||
api: {
|
||||
id: "test-model",
|
||||
url: "https://openrouter.ai",
|
||||
npm: "@openrouter/ai-sdk-provider",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("gpt models return OPENAI_EFFORTS with reasoning", () => {
|
||||
const model = createMockModel({
|
||||
id: "openrouter/gpt-4",
|
||||
providerID: "openrouter",
|
||||
api: {
|
||||
id: "gpt-4",
|
||||
url: "https://openrouter.ai",
|
||||
npm: "@openrouter/ai-sdk-provider",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
|
||||
expect(result.low).toEqual({ reasoning: { effort: "low" } })
|
||||
expect(result.high).toEqual({ reasoning: { effort: "high" } })
|
||||
})
|
||||
|
||||
test("gemini-3 returns OPENAI_EFFORTS with reasoning", () => {
|
||||
const model = createMockModel({
|
||||
id: "openrouter/gemini-3-5-pro",
|
||||
providerID: "openrouter",
|
||||
api: {
|
||||
id: "gemini-3-5-pro",
|
||||
url: "https://openrouter.ai",
|
||||
npm: "@openrouter/ai-sdk-provider",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
|
||||
})
|
||||
|
||||
test("grok-4 returns OPENAI_EFFORTS with reasoning", () => {
|
||||
const model = createMockModel({
|
||||
id: "openrouter/grok-4",
|
||||
providerID: "openrouter",
|
||||
api: {
|
||||
id: "grok-4",
|
||||
url: "https://openrouter.ai",
|
||||
npm: "@openrouter/ai-sdk-provider",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/gateway", () => {
|
||||
test("returns OPENAI_EFFORTS with reasoningEffort", () => {
|
||||
const model = createMockModel({
|
||||
id: "gateway/gateway-model",
|
||||
providerID: "gateway",
|
||||
api: {
|
||||
id: "gateway-model",
|
||||
url: "https://gateway.ai",
|
||||
npm: "@ai-sdk/gateway",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
|
||||
expect(result.low).toEqual({ reasoningEffort: "low" })
|
||||
expect(result.high).toEqual({ reasoningEffort: "high" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/cerebras", () => {
|
||||
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
|
||||
const model = createMockModel({
|
||||
id: "cerebras/llama-4",
|
||||
providerID: "cerebras",
|
||||
api: {
|
||||
id: "llama-4-sc",
|
||||
url: "https://api.cerebras.ai",
|
||||
npm: "@ai-sdk/cerebras",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
|
||||
expect(result.low).toEqual({ reasoningEffort: "low" })
|
||||
expect(result.high).toEqual({ reasoningEffort: "high" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/togetherai", () => {
|
||||
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
|
||||
const model = createMockModel({
|
||||
id: "togetherai/llama-4",
|
||||
providerID: "togetherai",
|
||||
api: {
|
||||
id: "llama-4-sc",
|
||||
url: "https://api.togetherai.com",
|
||||
npm: "@ai-sdk/togetherai",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
|
||||
expect(result.low).toEqual({ reasoningEffort: "low" })
|
||||
expect(result.high).toEqual({ reasoningEffort: "high" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/xai", () => {
|
||||
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
|
||||
const model = createMockModel({
|
||||
id: "xai/grok-3",
|
||||
providerID: "xai",
|
||||
api: {
|
||||
id: "grok-3",
|
||||
url: "https://api.x.ai",
|
||||
npm: "@ai-sdk/xai",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
|
||||
expect(result.low).toEqual({ reasoningEffort: "low" })
|
||||
expect(result.high).toEqual({ reasoningEffort: "high" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/deepinfra", () => {
|
||||
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
|
||||
const model = createMockModel({
|
||||
id: "deepinfra/llama-4",
|
||||
providerID: "deepinfra",
|
||||
api: {
|
||||
id: "llama-4-sc",
|
||||
url: "https://api.deepinfra.com",
|
||||
npm: "@ai-sdk/deepinfra",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
|
||||
expect(result.low).toEqual({ reasoningEffort: "low" })
|
||||
expect(result.high).toEqual({ reasoningEffort: "high" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/openai-compatible", () => {
|
||||
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
|
||||
const model = createMockModel({
|
||||
id: "custom-provider/custom-model",
|
||||
providerID: "custom-provider",
|
||||
api: {
|
||||
id: "custom-model",
|
||||
url: "https://api.custom.com",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
|
||||
expect(result.low).toEqual({ reasoningEffort: "low" })
|
||||
expect(result.high).toEqual({ reasoningEffort: "high" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/azure", () => {
|
||||
test("o1-mini returns empty object", () => {
|
||||
const model = createMockModel({
|
||||
id: "o1-mini",
|
||||
providerID: "azure",
|
||||
api: {
|
||||
id: "o1-mini",
|
||||
url: "https://azure.com",
|
||||
npm: "@ai-sdk/azure",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("standard azure models return custom efforts with reasoningSummary", () => {
|
||||
const model = createMockModel({
|
||||
id: "azure/gpt-4o",
|
||||
providerID: "azure",
|
||||
api: {
|
||||
id: "gpt-4o",
|
||||
url: "https://azure.com",
|
||||
npm: "@ai-sdk/azure",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
|
||||
expect(result.low).toEqual({
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
})
|
||||
})
|
||||
|
||||
test("gpt-5 adds minimal effort", () => {
|
||||
const model = createMockModel({
|
||||
id: "azure/gpt-5",
|
||||
providerID: "azure",
|
||||
api: {
|
||||
id: "gpt-5",
|
||||
url: "https://azure.com",
|
||||
npm: "@ai-sdk/azure",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/openai", () => {
|
||||
test("gpt-5-pro returns empty object", () => {
|
||||
const model = createMockModel({
|
||||
id: "gpt-5-pro",
|
||||
providerID: "openai",
|
||||
api: {
|
||||
id: "gpt-5-pro",
|
||||
url: "https://api.openai.com",
|
||||
npm: "@ai-sdk/openai",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("standard openai models return custom efforts with reasoningSummary", () => {
|
||||
const model = createMockModel({
|
||||
id: "openai/gpt-4o",
|
||||
providerID: "openai",
|
||||
api: {
|
||||
id: "gpt-4o",
|
||||
url: "https://api.openai.com",
|
||||
npm: "@ai-sdk/openai",
|
||||
},
|
||||
release_date: "2024-06-01",
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"])
|
||||
expect(result.low).toEqual({
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
})
|
||||
})
|
||||
|
||||
test("models after 2025-11-13 include 'none' effort", () => {
|
||||
const model = createMockModel({
|
||||
id: "openai/gpt-4.5",
|
||||
providerID: "openai",
|
||||
api: {
|
||||
id: "gpt-4.5",
|
||||
url: "https://api.openai.com",
|
||||
npm: "@ai-sdk/openai",
|
||||
},
|
||||
release_date: "2025-11-14",
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high"])
|
||||
})
|
||||
|
||||
test("models after 2025-12-04 include 'xhigh' effort", () => {
|
||||
const model = createMockModel({
|
||||
id: "openai/gpt-5-chat",
|
||||
providerID: "openai",
|
||||
api: {
|
||||
id: "gpt-5-chat",
|
||||
url: "https://api.openai.com",
|
||||
npm: "@ai-sdk/openai",
|
||||
},
|
||||
release_date: "2025-12-05",
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/anthropic", () => {
|
||||
test("returns high and max with thinking config", () => {
|
||||
const model = createMockModel({
|
||||
id: "anthropic/claude-4",
|
||||
providerID: "anthropic",
|
||||
api: {
|
||||
id: "claude-4",
|
||||
url: "https://api.anthropic.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["high", "max"])
|
||||
expect(result.high).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budgetTokens: 16000,
|
||||
},
|
||||
})
|
||||
expect(result.max).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budgetTokens: 31999,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/amazon-bedrock", () => {
|
||||
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => {
|
||||
const model = createMockModel({
|
||||
id: "bedrock/llama-4",
|
||||
providerID: "bedrock",
|
||||
api: {
|
||||
id: "llama-4-sc",
|
||||
url: "https://bedrock.amazonaws.com",
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
|
||||
expect(result.low).toEqual({
|
||||
reasoningConfig: {
|
||||
type: "enabled",
|
||||
maxReasoningEffort: "low",
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/google", () => {
|
||||
test("gemini-2.5 returns high and max with thinkingConfig and thinkingBudget", () => {
|
||||
const model = createMockModel({
|
||||
id: "google/gemini-2.5-pro",
|
||||
providerID: "google",
|
||||
api: {
|
||||
id: "gemini-2.5-pro",
|
||||
url: "https://generativelanguage.googleapis.com",
|
||||
npm: "@ai-sdk/google",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["high", "max"])
|
||||
expect(result.high).toEqual({
|
||||
thinkingConfig: {
|
||||
includeThoughts: true,
|
||||
thinkingBudget: 16000,
|
||||
},
|
||||
})
|
||||
expect(result.max).toEqual({
|
||||
thinkingConfig: {
|
||||
includeThoughts: true,
|
||||
thinkingBudget: 24576,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("other gemini models return low and high with thinkingLevel", () => {
|
||||
const model = createMockModel({
|
||||
id: "google/gemini-2.0-pro",
|
||||
providerID: "google",
|
||||
api: {
|
||||
id: "gemini-2.0-pro",
|
||||
url: "https://generativelanguage.googleapis.com",
|
||||
npm: "@ai-sdk/google",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "high"])
|
||||
expect(result.low).toEqual({
|
||||
includeThoughts: true,
|
||||
thinkingLevel: "low",
|
||||
})
|
||||
expect(result.high).toEqual({
|
||||
includeThoughts: true,
|
||||
thinkingLevel: "high",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/google-vertex", () => {
|
||||
test("gemini-2.5 returns high and max with thinkingConfig and thinkingBudget", () => {
|
||||
const model = createMockModel({
|
||||
id: "google-vertex/gemini-2.5-pro",
|
||||
providerID: "google-vertex",
|
||||
api: {
|
||||
id: "gemini-2.5-pro",
|
||||
url: "https://vertexai.googleapis.com",
|
||||
npm: "@ai-sdk/google-vertex",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["high", "max"])
|
||||
})
|
||||
|
||||
test("other vertex models return low and high with thinkingLevel", () => {
|
||||
const model = createMockModel({
|
||||
id: "google-vertex/gemini-2.0-pro",
|
||||
providerID: "google-vertex",
|
||||
api: {
|
||||
id: "gemini-2.0-pro",
|
||||
url: "https://vertexai.googleapis.com",
|
||||
npm: "@ai-sdk/google-vertex",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "high"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/cohere", () => {
|
||||
test("returns empty object", () => {
|
||||
const model = createMockModel({
|
||||
id: "cohere/command-r",
|
||||
providerID: "cohere",
|
||||
api: {
|
||||
id: "command-r",
|
||||
url: "https://api.cohere.com",
|
||||
npm: "@ai-sdk/cohere",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/groq", () => {
|
||||
test("returns none and WIDELY_SUPPORTED_EFFORTS with thinkingLevel", () => {
|
||||
const model = createMockModel({
|
||||
id: "groq/llama-4",
|
||||
providerID: "groq",
|
||||
api: {
|
||||
id: "llama-4-sc",
|
||||
url: "https://api.groq.com",
|
||||
npm: "@ai-sdk/groq",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["none", "low", "medium", "high"])
|
||||
expect(result.none).toEqual({
|
||||
includeThoughts: true,
|
||||
thinkingLevel: "none",
|
||||
})
|
||||
expect(result.low).toEqual({
|
||||
includeThoughts: true,
|
||||
thinkingLevel: "low",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/perplexity", () => {
|
||||
test("returns empty object", () => {
|
||||
const model = createMockModel({
|
||||
id: "perplexity/sonar-plus",
|
||||
providerID: "perplexity",
|
||||
api: {
|
||||
id: "sonar-plus",
|
||||
url: "https://api.perplexity.ai",
|
||||
npm: "@ai-sdk/perplexity",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -1177,6 +1177,10 @@ export type ServerConfig = {
|
||||
* Enable mDNS service discovery
|
||||
*/
|
||||
mdns?: boolean
|
||||
/**
|
||||
* Additional domains to allow for CORS
|
||||
*/
|
||||
cors?: Array<string>
|
||||
}
|
||||
|
||||
export type AgentConfig = {
|
||||
@@ -1309,6 +1313,18 @@ export type ProviderConfig = {
|
||||
provider?: {
|
||||
npm: string
|
||||
}
|
||||
/**
|
||||
* Variant-specific configuration
|
||||
*/
|
||||
variants?: {
|
||||
[key: string]: {
|
||||
/**
|
||||
* Disable this variant for the model
|
||||
*/
|
||||
disabled?: boolean
|
||||
[key: string]: unknown | boolean | undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
whitelist?: Array<string>
|
||||
@@ -1639,6 +1655,10 @@ export type Config = {
|
||||
* Continue the agent loop when a tool call is denied
|
||||
*/
|
||||
continue_loop_on_deny?: boolean
|
||||
/**
|
||||
* Timeout in milliseconds for model context protocol (MCP) requests
|
||||
*/
|
||||
mcp_timeout?: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1715,11 +1735,7 @@ export type Command = {
|
||||
model?: string
|
||||
template: string
|
||||
subtask?: boolean
|
||||
}
|
||||
|
||||
export type Variant = {
|
||||
disabled: boolean
|
||||
[key: string]: unknown | boolean
|
||||
hints: Array<string>
|
||||
}
|
||||
|
||||
export type Model = {
|
||||
@@ -1786,7 +1802,9 @@ export type Model = {
|
||||
}
|
||||
release_date: string
|
||||
variants?: {
|
||||
[key: string]: Variant
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3497,6 +3515,11 @@ export type ProviderListResponses = {
|
||||
provider?: {
|
||||
npm: string
|
||||
}
|
||||
variants?: {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}>
|
||||
|
||||
+63
-13
@@ -3209,6 +3209,19 @@
|
||||
}
|
||||
},
|
||||
"required": ["npm"]
|
||||
},
|
||||
"variants": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -7780,6 +7793,13 @@
|
||||
"mdns": {
|
||||
"description": "Enable mDNS service discovery",
|
||||
"type": "boolean"
|
||||
},
|
||||
"cors": {
|
||||
"description": "Additional domains to allow for CORS",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -8061,6 +8081,23 @@
|
||||
}
|
||||
},
|
||||
"required": ["npm"]
|
||||
},
|
||||
"variants": {
|
||||
"description": "Variant-specific configuration",
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"disabled": {
|
||||
"description": "Disable this variant for the model",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8737,6 +8774,12 @@
|
||||
"continue_loop_on_deny": {
|
||||
"description": "Continue the agent loop when a tool call is denied",
|
||||
"type": "boolean"
|
||||
},
|
||||
"mcp_timeout": {
|
||||
"description": "Timeout in milliseconds for model context protocol (MCP) requests",
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8940,23 +8983,26 @@
|
||||
"type": "string"
|
||||
},
|
||||
"template": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"subtask": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hints": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["name", "template"]
|
||||
},
|
||||
"Variant": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["disabled"],
|
||||
"additionalProperties": {}
|
||||
"required": ["name", "template", "hints"]
|
||||
},
|
||||
"Model": {
|
||||
"type": "object",
|
||||
@@ -9154,7 +9200,11 @@
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Variant"
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./src/components/*.tsx",
|
||||
|
||||
@@ -377,6 +377,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="user-message"] [data-slot="user-message-text"],
|
||||
[data-component="text-part"],
|
||||
[data-component="reasoning-part"],
|
||||
[data-component="tool-error"],
|
||||
[data-component="tool-output"],
|
||||
[data-component="edit-content"],
|
||||
[data-component="write-content"],
|
||||
[data-component="todos"],
|
||||
[data-component="diagnostics"] {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
[data-component="tool-part-wrapper"] {
|
||||
width: 100%;
|
||||
|
||||
|
||||
@@ -874,7 +874,6 @@ ToolRegistry.register({
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
defaultOpen
|
||||
icon="code-lines"
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
@@ -926,7 +925,6 @@ ToolRegistry.register({
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
defaultOpen
|
||||
icon="code-lines"
|
||||
trigger={
|
||||
<div data-component="write-trigger">
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="accordion-content"] {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
[data-slot="session-review-trigger-content"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -116,6 +116,11 @@
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="session-turn-markdown"],
|
||||
[data-slot="session-turn-accordion"] [data-slot="accordion-content"] {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-markdown"] {
|
||||
&[data-diffs="true"] {
|
||||
font-size: 15px;
|
||||
|
||||
@@ -78,6 +78,7 @@ function AssistantMessageItem(props: {
|
||||
message: AssistantMessage
|
||||
responsePartId: string | undefined
|
||||
hideResponsePart: boolean
|
||||
hideReasoning: boolean
|
||||
}) {
|
||||
const data = useData()
|
||||
const emptyParts: PartType[] = []
|
||||
@@ -92,7 +93,12 @@ function AssistantMessageItem(props: {
|
||||
})
|
||||
|
||||
const filteredParts = createMemo(() => {
|
||||
const parts = msgParts()
|
||||
let parts = msgParts()
|
||||
|
||||
if (props.hideReasoning) {
|
||||
parts = parts.filter((part) => part?.type !== "reasoning")
|
||||
}
|
||||
|
||||
if (!props.hideResponsePart) return parts
|
||||
|
||||
const responsePartId = props.responsePartId
|
||||
@@ -556,6 +562,7 @@ export function SessionTurn(
|
||||
message={assistantMessage}
|
||||
responsePartId={responsePartId()}
|
||||
hideResponsePart={hideResponsePart()}
|
||||
hideReasoning={!working()}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -407,11 +407,12 @@ opencode stats
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Description |
|
||||
| ----------- | --------------------------------------------------------------- |
|
||||
| `--days` | Show stats for the last N days (all time) |
|
||||
| `--tools` | Number of tools to show (all) |
|
||||
| `--project` | Filter by project (all projects, empty string: current project) |
|
||||
| Flag | Description |
|
||||
| ----------- | --------------------------------------------------------------------------- |
|
||||
| `--days` | Show stats for the last N days (all time) |
|
||||
| `--tools` | Number of tools to show (all) |
|
||||
| `--models` | Show model usage breakdown (hidden by default). Pass a number to show top N |
|
||||
| `--project` | Filter by project (all projects, empty string: current project) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ OpenCode comes with several built-in formatters for popular languages and framew
|
||||
| clang-format | .c, .cpp, .h, .hpp, .ino, and [more](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` config file |
|
||||
| ktlint | .kt, .kts | `ktlint` command available |
|
||||
| ruff | .py, .pyi | `ruff` command available with config |
|
||||
| rustfmt | .rs | `rustfmt` command available |
|
||||
| uv | .py, .pyi | `uv` command available |
|
||||
| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available |
|
||||
| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available |
|
||||
|
||||
@@ -43,6 +43,7 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
|
||||
"model_list": "<leader>m",
|
||||
"model_cycle_recent": "f2",
|
||||
"model_cycle_recent_reverse": "shift+f2",
|
||||
"variant_cycle": "ctrl+t",
|
||||
"command_list": "ctrl+p",
|
||||
"agent_list": "<leader>a",
|
||||
"agent_cycle": "tab",
|
||||
@@ -118,6 +119,29 @@ You can disable a keybind by adding the key to your config with a value of "none
|
||||
|
||||
---
|
||||
|
||||
## Desktop prompt shortcuts
|
||||
|
||||
The OpenCode desktop app prompt input supports common Readline/Emacs-style shortcuts for editing text. These are built-in and currently not configurable via `opencode.json`.
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------- | ---------------------------------------- |
|
||||
| `ctrl+a` | Move to start of current line |
|
||||
| `ctrl+e` | Move to end of current line |
|
||||
| `ctrl+b` | Move cursor back one character |
|
||||
| `ctrl+f` | Move cursor forward one character |
|
||||
| `alt+b` | Move cursor back one word |
|
||||
| `alt+f` | Move cursor forward one word |
|
||||
| `ctrl+d` | Delete character under cursor |
|
||||
| `ctrl+k` | Kill to end of line |
|
||||
| `ctrl+u` | Kill to start of line |
|
||||
| `ctrl+w` | Kill previous word |
|
||||
| `alt+d` | Kill next word |
|
||||
| `ctrl+y` | Yank (paste) last killed text |
|
||||
| `ctrl+t` | Transpose characters |
|
||||
| `ctrl+g` | Cancel popovers / abort running response |
|
||||
|
||||
---
|
||||
|
||||
## Shift+Enter
|
||||
|
||||
Some terminals don't send modifier keys with Enter by default. You may need to configure your terminal to send `Shift+Enter` as an escape sequence.
|
||||
|
||||
@@ -31,6 +31,7 @@ OpenCode comes with several built-in LSP servers for popular languages:
|
||||
| ocaml-lsp | .ml, .mli | `ocamllsp` command available |
|
||||
| oxlint | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, .vue, .astro, .svelte | `oxlint` dependency in project |
|
||||
| php intelephense | .php | Auto-installs for PHP projects |
|
||||
| prisma | .prisma | `prisma` command available |
|
||||
| pyright | .py, .pyi | `pyright` dependency installed |
|
||||
| ruby-lsp (rubocop) | .rb, .rake, .gemspec, .ru | `ruby` and `gem` commands available |
|
||||
| rust | .rs | `rust-analyzer` command available |
|
||||
|
||||
@@ -35,15 +35,13 @@ Consider using one of the models we recommend.
|
||||
|
||||
However, there are only a few of them that are good at both generating code and tool calling.
|
||||
|
||||
Here are several models that work well with OpenCode, in no particular order. (This is not an exhaustive list):
|
||||
Here are several models that work well with OpenCode, in no particular order. (This is not an exhaustive list nor is it necessarily up to date):
|
||||
|
||||
- GPT 5.1
|
||||
- GPT 5.2
|
||||
- GPT 5.1 Codex
|
||||
- Claude Opus 4.5
|
||||
- Claude Sonnet 4.5
|
||||
- Claude Haiku 4.5
|
||||
- Kimi K2
|
||||
- GLM 4.6
|
||||
- Qwen3 Coder
|
||||
- Minimax M2.1
|
||||
- Gemini 3 Pro
|
||||
|
||||
---
|
||||
@@ -107,30 +105,26 @@ The built-in provider and model names can be found on [Models.dev](https://model
|
||||
|
||||
You can also configure these options for any agents that you are using. The agent config overrides any global options here. [Learn more](/docs/agents/#additional).
|
||||
|
||||
You can also define custom models that extend built-in ones and can optionally use specific options by referring to their id:
|
||||
You can also define custom variants that extend built-in ones. Variants let you configure different settings for the same model without creating duplicate entries:
|
||||
|
||||
```jsonc title="opencode.jsonc" {6-20}
|
||||
```jsonc title="opencode.jsonc" {6-21}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"opencode": {
|
||||
"models": {
|
||||
"gpt-5-high": {
|
||||
"id": "gpt-5",
|
||||
"name": "MyGPT5 (High Reasoning)",
|
||||
"options": {
|
||||
"reasoningEffort": "high",
|
||||
"textVerbosity": "low",
|
||||
"reasoningSummary": "auto",
|
||||
},
|
||||
},
|
||||
"gpt-5-low": {
|
||||
"id": "gpt-5",
|
||||
"name": "MyGPT5 (Low Reasoning)",
|
||||
"options": {
|
||||
"reasoningEffort": "low",
|
||||
"textVerbosity": "low",
|
||||
"reasoningSummary": "auto",
|
||||
"gpt-5": {
|
||||
"variants": {
|
||||
"high": {
|
||||
"reasoningEffort": "high",
|
||||
"textVerbosity": "low",
|
||||
"reasoningSummary": "auto",
|
||||
},
|
||||
"low": {
|
||||
"reasoningEffort": "low",
|
||||
"textVerbosity": "low",
|
||||
"reasoningSummary": "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -141,6 +135,72 @@ You can also define custom models that extend built-in ones and can optionally u
|
||||
|
||||
---
|
||||
|
||||
## Variants
|
||||
|
||||
Many models support multiple variants with different configurations. OpenCode ships with built-in default variants for popular providers.
|
||||
|
||||
### Built-in variants
|
||||
|
||||
OpenCode ships with default variants for many providers:
|
||||
|
||||
**Anthropic**:
|
||||
|
||||
- `high` - High thinking budget (default)
|
||||
- `max` - Maximum thinking budget
|
||||
|
||||
**OpenAI**:
|
||||
|
||||
Varies by model but roughly:
|
||||
|
||||
- `none` - No reasoning
|
||||
- `minimal` - Minimal reasoning effort
|
||||
- `low` - Low reasoning effort
|
||||
- `medium` - Medium reasoning effort
|
||||
- `high` - High reasoning effort
|
||||
- `xhigh` - Extra high reasoning effort
|
||||
|
||||
**Google**:
|
||||
|
||||
- `low` - Lower effort/token budget
|
||||
- `high` - Higher effort/token budget
|
||||
|
||||
:::tip
|
||||
This list is not comprehensive. Many other providers have built-in defaults too.
|
||||
:::
|
||||
|
||||
### Custom variants
|
||||
|
||||
You can override existing variants or add your own:
|
||||
|
||||
```jsonc title="opencode.jsonc" {7-18}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"openai": {
|
||||
"models": {
|
||||
"gpt-5": {
|
||||
"variants": {
|
||||
"thinking": {
|
||||
"reasoningEffort": "high",
|
||||
"textVerbosity": "low",
|
||||
},
|
||||
"fast": {
|
||||
"disabled": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Cycle variants
|
||||
|
||||
Use the keybind `variant_cycle` to quickly switch between variants. [Learn more](/docs/keybinds).
|
||||
|
||||
---
|
||||
|
||||
## Loading models
|
||||
|
||||
When OpenCode starts up, it checks for models in the following priority order:
|
||||
|
||||
@@ -230,6 +230,10 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr
|
||||
|
||||
We are using `osascript` to run AppleScript on macOS. Here we are using it to send notifications.
|
||||
|
||||
:::note
|
||||
If you’re using the OpenCode desktop app, it can send system notifications automatically when a response is ready or when a session errors.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
### .env protection
|
||||
|
||||
@@ -283,13 +283,19 @@ await client.session.prompt({
|
||||
|
||||
### Files
|
||||
|
||||
| Method | Description | Response |
|
||||
| ------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `find.text({ query })` | Search for text in files | Array of match objects with `path`, `lines`, `line_number`, `absolute_offset`, `submatches` |
|
||||
| `find.files({ query })` | Find files by name | `string[]` (file paths) |
|
||||
| `find.symbols({ query })` | Find workspace symbols | <a href={typesUrl}><code>Symbol[]</code></a> |
|
||||
| `file.read({ query })` | Read a file | `{ type: "raw" \| "patch", content: string }` |
|
||||
| `file.status({ query? })` | Get status for tracked files | <a href={typesUrl}><code>File[]</code></a> |
|
||||
| Method | Description | Response |
|
||||
| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `find.text({ query })` | Search for text in files | Array of match objects with `path`, `lines`, `line_number`, `absolute_offset`, `submatches` |
|
||||
| `find.files({ query })` | Find files and directories by name | `string[]` (paths) |
|
||||
| `find.symbols({ query })` | Find workspace symbols | <a href={typesUrl}><code>Symbol[]</code></a> |
|
||||
| `file.read({ query })` | Read a file | `{ type: "raw" \| "patch", content: string }` |
|
||||
| `file.status({ query? })` | Get status for tracked files | <a href={typesUrl}><code>File[]</code></a> |
|
||||
|
||||
`find.files` supports a few optional query fields:
|
||||
|
||||
- `type`: `"file"` or `"directory"`
|
||||
- `directory`: override the project root for the search
|
||||
- `limit`: max results (1–200)
|
||||
|
||||
---
|
||||
|
||||
@@ -302,7 +308,11 @@ const textResults = await client.find.text({
|
||||
})
|
||||
|
||||
const files = await client.find.files({
|
||||
query: { query: "*.ts" },
|
||||
query: { query: "*.ts", type: "file" },
|
||||
})
|
||||
|
||||
const directories = await client.find.files({
|
||||
query: { query: "packages", type: "directory", limit: 20 },
|
||||
})
|
||||
|
||||
const content = await client.file.read({
|
||||
|
||||
@@ -173,14 +173,22 @@ The opencode server exposes the following APIs.
|
||||
|
||||
### Files
|
||||
|
||||
| Method | Path | Description | Response |
|
||||
| ------ | ------------------------ | ---------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/find?pattern=<pat>` | Search for text in files | Array of match objects with `path`, `lines`, `line_number`, `absolute_offset`, `submatches` |
|
||||
| `GET` | `/find/file?query=<q>` | Find files by name | `string[]` (file paths) |
|
||||
| `GET` | `/find/symbol?query=<q>` | Find workspace symbols | <a href={typesUrl}><code>Symbol[]</code></a> |
|
||||
| `GET` | `/file?path=<path>` | List files and directories | <a href={typesUrl}><code>FileNode[]</code></a> |
|
||||
| `GET` | `/file/content?path=<p>` | Read a file | <a href={typesUrl}><code>FileContent</code></a> |
|
||||
| `GET` | `/file/status` | Get status for tracked files | <a href={typesUrl}><code>File[]</code></a> |
|
||||
| Method | Path | Description | Response |
|
||||
| ------ | ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/find?pattern=<pat>` | Search for text in files | Array of match objects with `path`, `lines`, `line_number`, `absolute_offset`, `submatches` |
|
||||
| `GET` | `/find/file?query=<q>` | Find files and directories by name | `string[]` (paths) |
|
||||
| `GET` | `/find/symbol?query=<q>` | Find workspace symbols | <a href={typesUrl}><code>Symbol[]</code></a> |
|
||||
| `GET` | `/file?path=<path>` | List files and directories | <a href={typesUrl}><code>FileNode[]</code></a> |
|
||||
| `GET` | `/file/content?path=<p>` | Read a file | <a href={typesUrl}><code>FileContent</code></a> |
|
||||
| `GET` | `/file/status` | Get status for tracked files | <a href={typesUrl}><code>File[]</code></a> |
|
||||
|
||||
#### `/find/file` query parameters
|
||||
|
||||
- `query` (required) — search string (fuzzy match)
|
||||
- `type` (optional) — limit results to `"file"` or `"directory"`
|
||||
- `directory` (optional) — override the project root for the search
|
||||
- `limit` (optional) — max results (1–200)
|
||||
- `dirs` (optional) — legacy flag (`"false"` returns only files)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -156,6 +156,26 @@ The free models:
|
||||
|
||||
---
|
||||
|
||||
### Auto-reload
|
||||
|
||||
If your balance goes below $5, Zen will automatically reload $20 (plus $1.23
|
||||
processing fee).
|
||||
|
||||
You can change the auto-reload amount. You can also disable auto-reload entirely.
|
||||
|
||||
---
|
||||
|
||||
### Monthly limits
|
||||
|
||||
You can also set a monthly usage limit for the entire workspace and for each
|
||||
member of your team.
|
||||
|
||||
For example, let's say you set a monthly usage limit to $20, Zen will not use
|
||||
more than $20 in a month. But if you have auto-reload enabled, Zen might end up
|
||||
charging you more than $20 if your balance goes below $5.
|
||||
|
||||
---
|
||||
|
||||
## Privacy
|
||||
|
||||
All our models are hosted in the US. Our providers follow a zero-retention policy and do not use your data for model training, with the following exceptions:
|
||||
|
||||
Executable
+249
@@ -0,0 +1,249 @@
|
||||
#!/bin/bash
|
||||
|
||||
# First-Time Contributor Analyzer
|
||||
# Analyzes PRs from first-time contributors over the last 4 weeks
|
||||
# Usage: ./scripts/analyze-first-time-contributors.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="sst/opencode"
|
||||
GITHUB_API="https://api.github.com/repos"
|
||||
FOUR_WEEKS_AGO=$(date -u -v-28d '+%Y-%m-%dT00:00:00Z' 2>/dev/null || date -u -d '4 weeks ago' '+%Y-%m-%dT00:00:00Z')
|
||||
|
||||
echo "Analyzing first-time contributors from last 4 weeks..."
|
||||
echo "Start date: $FOUR_WEEKS_AGO"
|
||||
echo ""
|
||||
|
||||
# Create temp files
|
||||
TEMP_PRS=$(mktemp)
|
||||
TEMP_CONTRIBUTORS=$(mktemp)
|
||||
trap "rm -f $TEMP_PRS $TEMP_CONTRIBUTORS" EXIT
|
||||
|
||||
# Fetch all PRs from the last 4 weeks
|
||||
echo "Fetching PRs..."
|
||||
ALL_PRS="[]"
|
||||
for page in {1..10}; do
|
||||
echo " Page $page..."
|
||||
PAGE_DATA=$(curl -s "${GITHUB_API}/${REPO}/pulls?state=all&sort=created&direction=desc&per_page=100&page=${page}")
|
||||
|
||||
COUNT=$(echo "$PAGE_DATA" | jq 'length')
|
||||
if [ "$COUNT" -eq 0 ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
FILTERED=$(echo "$PAGE_DATA" | jq "[.[] | select(.created_at >= \"${FOUR_WEEKS_AGO}\")]")
|
||||
ALL_PRS=$(echo "$ALL_PRS" "$FILTERED" | jq -s '.[0] + .[1]')
|
||||
|
||||
OLDEST=$(echo "$PAGE_DATA" | jq -r '.[-1].created_at')
|
||||
if [[ "$OLDEST" < "$FOUR_WEEKS_AGO" ]]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$ALL_PRS" > "$TEMP_PRS"
|
||||
PR_COUNT=$(jq 'length' "$TEMP_PRS")
|
||||
echo " Found $PR_COUNT PRs"
|
||||
|
||||
echo ""
|
||||
echo "Checking contributor status for each PR..."
|
||||
|
||||
# Get contributors list (people with previous PRs)
|
||||
# For each PR, check if the author has "first-time contributor" label or
|
||||
# if this is their first PR to the repo
|
||||
|
||||
# Extract PR data with author info
|
||||
jq -r '.[] | "\(.number)|\(.user.login)|\(.created_at)|\(.author_association)"' "$TEMP_PRS" > "$TEMP_CONTRIBUTORS"
|
||||
|
||||
echo ""
|
||||
|
||||
# Analyze with Python
|
||||
PYTHON_SCRIPT=$(mktemp)
|
||||
trap "rm -f $PYTHON_SCRIPT $TEMP_PRS $TEMP_CONTRIBUTORS" EXIT
|
||||
|
||||
cat > "$PYTHON_SCRIPT" << 'EOF'
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
# Read PR data
|
||||
pr_data = []
|
||||
with open(sys.argv[1], 'r') as f:
|
||||
for line in f:
|
||||
if line.strip():
|
||||
parts = line.strip().split('|')
|
||||
pr_data.append({
|
||||
'number': parts[0],
|
||||
'author': parts[1],
|
||||
'created_at': parts[2],
|
||||
'author_association': parts[3]
|
||||
})
|
||||
|
||||
print(f"Analyzing {len(pr_data)} PRs...\n")
|
||||
|
||||
# Categorize by week
|
||||
def get_week_label(date_str):
|
||||
date = datetime.fromisoformat(date_str.replace('Z', '+00:00')).replace(tzinfo=None)
|
||||
|
||||
if date >= datetime(2025, 12, 22):
|
||||
return "Week 51: Dec 22-26"
|
||||
elif date >= datetime(2025, 12, 15):
|
||||
return "Week 50: Dec 15-21"
|
||||
elif date >= datetime(2025, 12, 8):
|
||||
return "Week 49: Dec 8-14"
|
||||
elif date >= datetime(2025, 12, 1):
|
||||
return "Week 48: Dec 1-7"
|
||||
else:
|
||||
return "Earlier"
|
||||
|
||||
# First-time contributors have author_association of "FIRST_TIME_CONTRIBUTOR" or "NONE"
|
||||
# or sometimes "CONTRIBUTOR" for their first few PRs
|
||||
|
||||
by_week = defaultdict(lambda: {
|
||||
'total': 0,
|
||||
'first_time': 0,
|
||||
'returning': 0,
|
||||
'first_time_authors': set()
|
||||
})
|
||||
|
||||
all_authors = defaultdict(int)
|
||||
|
||||
for pr in pr_data:
|
||||
week = get_week_label(pr['created_at'])
|
||||
author = pr['author']
|
||||
assoc = pr['author_association']
|
||||
|
||||
by_week[week]['total'] += 1
|
||||
all_authors[author] += 1
|
||||
|
||||
# GitHub marks first-time contributors explicitly
|
||||
# FIRST_TIME_CONTRIBUTOR = first PR to this repo
|
||||
# NONE = no association (could be first time)
|
||||
# For more accuracy, we check if author appears only once in our dataset
|
||||
|
||||
if assoc == 'FIRST_TIME_CONTRIBUTOR' or (assoc == 'NONE' and all_authors[author] == 1):
|
||||
by_week[week]['first_time'] += 1
|
||||
by_week[week]['first_time_authors'].add(author)
|
||||
else:
|
||||
by_week[week]['returning'] += 1
|
||||
|
||||
# Print results
|
||||
print("="*90)
|
||||
print("FIRST-TIME CONTRIBUTOR ANALYSIS - LAST 4 WEEKS")
|
||||
print("="*90 + "\n")
|
||||
|
||||
weeks = ["Week 48: Dec 1-7", "Week 49: Dec 8-14", "Week 50: Dec 15-21", "Week 51: Dec 22-26"]
|
||||
|
||||
print("PRs by Contributor Type:\n")
|
||||
for week in weeks:
|
||||
if week in by_week:
|
||||
data = by_week[week]
|
||||
total = data['total']
|
||||
first_time = data['first_time']
|
||||
returning = data['returning']
|
||||
first_time_pct = (first_time / total * 100) if total > 0 else 0
|
||||
|
||||
print(f"{week}: {total} PRs")
|
||||
print(f" ✨ First-time contributors: {first_time} ({first_time_pct:.1f}%)")
|
||||
print(f" ↩️ Returning contributors: {returning} ({100-first_time_pct:.1f}%)")
|
||||
print()
|
||||
|
||||
# Overall summary
|
||||
total_prs = sum(data['total'] for data in by_week.values())
|
||||
total_first_time = sum(data['first_time'] for data in by_week.values())
|
||||
total_returning = sum(data['returning'] for data in by_week.values())
|
||||
overall_first_time_pct = (total_first_time / total_prs * 100) if total_prs > 0 else 0
|
||||
|
||||
print("="*90)
|
||||
print("OVERALL SUMMARY")
|
||||
print("="*90 + "\n")
|
||||
|
||||
print(f"Total PRs (4 weeks): {total_prs}")
|
||||
print(f"From first-time contributors: {total_first_time} ({overall_first_time_pct:.1f}%)")
|
||||
print(f"From returning contributors: {total_returning} ({100-overall_first_time_pct:.1f}%)")
|
||||
|
||||
# Count unique first-time contributors
|
||||
all_first_time_authors = set()
|
||||
for data in by_week.values():
|
||||
all_first_time_authors.update(data['first_time_authors'])
|
||||
|
||||
print(f"\nUnique first-time contributors: {len(all_first_time_authors)}")
|
||||
|
||||
# Week by week trend
|
||||
print("\n" + "="*90)
|
||||
print("TREND ANALYSIS")
|
||||
print("="*90 + "\n")
|
||||
|
||||
print("First-Time Contributor Rate by Week:\n")
|
||||
for week in weeks:
|
||||
if week in by_week:
|
||||
data = by_week[week]
|
||||
rate = (data['first_time'] / data['total'] * 100) if data['total'] > 0 else 0
|
||||
bar = "█" * int(rate / 2)
|
||||
print(f" {week}: {rate:5.1f}% {bar}")
|
||||
|
||||
print("\n" + "="*90)
|
||||
print("KEY INSIGHTS")
|
||||
print("="*90 + "\n")
|
||||
|
||||
insights = []
|
||||
|
||||
if total_first_time > 0:
|
||||
insights.append(
|
||||
f"1. New Contributors: {total_first_time} PRs from first-timers shows healthy\n" +
|
||||
f" community growth and welcoming environment for new contributors."
|
||||
)
|
||||
|
||||
if overall_first_time_pct > 20:
|
||||
insights.append(
|
||||
f"2. High New Contributor Rate: {overall_first_time_pct:.1f}% from first-timers is\n" +
|
||||
f" excellent. Indicates strong onboarding and accessible contribution process."
|
||||
)
|
||||
elif overall_first_time_pct > 10:
|
||||
insights.append(
|
||||
f"2. Moderate New Contributor Rate: {overall_first_time_pct:.1f}% from first-timers\n" +
|
||||
f" is healthy. Good balance of new and returning contributors."
|
||||
)
|
||||
else:
|
||||
insights.append(
|
||||
f"2. Low New Contributor Rate: {overall_first_time_pct:.1f}% from first-timers.\n" +
|
||||
f" Most PRs from established contributors (mature project pattern)."
|
||||
)
|
||||
|
||||
# Check for trend
|
||||
week_rates = []
|
||||
for week in weeks:
|
||||
if week in by_week:
|
||||
data = by_week[week]
|
||||
rate = (data['first_time'] / data['total'] * 100) if data['total'] > 0 else 0
|
||||
week_rates.append(rate)
|
||||
|
||||
if len(week_rates) >= 3:
|
||||
if week_rates[-1] > week_rates[0]:
|
||||
insights.append(
|
||||
f"3. Growing Trend: First-time contributor rate increasing\n" +
|
||||
f" ({week_rates[0]:.1f}% → {week_rates[-1]:.1f}%). Project attracting more new contributors."
|
||||
)
|
||||
elif week_rates[-1] < week_rates[0]:
|
||||
insights.append(
|
||||
f"3. Declining Trend: First-time contributor rate decreasing\n" +
|
||||
f" ({week_rates[0]:.1f}% → {week_rates[-1]:.1f}%). May indicate shifting to core contributors."
|
||||
)
|
||||
else:
|
||||
insights.append(
|
||||
f"3. Stable Trend: First-time contributor rate relatively stable\n" +
|
||||
f" across weeks. Consistent new contributor engagement."
|
||||
)
|
||||
|
||||
insights.append(
|
||||
f"4. Unique Contributors: {len(all_first_time_authors)} unique new people made their\n" +
|
||||
f" first contribution. Shows breadth of community involvement."
|
||||
)
|
||||
|
||||
for insight in insights:
|
||||
print(f"{insight}\n")
|
||||
|
||||
print("="*90 + "\n")
|
||||
EOF
|
||||
|
||||
python3 "$PYTHON_SCRIPT" "$TEMP_CONTRIBUTORS"
|
||||
Executable
+219
@@ -0,0 +1,219 @@
|
||||
#!/bin/bash
|
||||
|
||||
# GitHub Issues Analyzer for Recent Weeks
|
||||
# Analyzes Dec 15-21 (Week 50) and Dec 22-26 (Week 51)
|
||||
# Usage: ./scripts/analyze-recent-weeks.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="sst/opencode"
|
||||
GITHUB_API="https://api.github.com/repos"
|
||||
|
||||
# Start from Dec 15
|
||||
START_DATE="2025-12-15T00:00:00Z"
|
||||
|
||||
echo "Analyzing GitHub issues from Dec 15 onwards..."
|
||||
echo "Start date: $START_DATE"
|
||||
echo ""
|
||||
|
||||
# Create temp file
|
||||
TEMP_FILE=$(mktemp)
|
||||
trap "rm -f $TEMP_FILE" EXIT
|
||||
|
||||
echo "[]" > "$TEMP_FILE"
|
||||
|
||||
# Fetch all issues from Dec 15 onwards (paginate through results)
|
||||
for page in {1..5}; do
|
||||
echo " Fetching page $page..."
|
||||
PAGE_DATA=$(curl -s "${GITHUB_API}/${REPO}/issues?state=all&sort=created&direction=desc&per_page=100&page=${page}")
|
||||
|
||||
# Check if we got any results
|
||||
COUNT=$(echo "$PAGE_DATA" | jq 'length')
|
||||
if [ "$COUNT" -eq 0 ]; then
|
||||
echo " No more results on page $page"
|
||||
break
|
||||
fi
|
||||
|
||||
# Filter issues from Dec 15 onwards
|
||||
FILTERED=$(echo "$PAGE_DATA" | jq "[.[] | select(.created_at >= \"${START_DATE}\")]")
|
||||
FILTERED_COUNT=$(echo "$FILTERED" | jq 'length')
|
||||
echo " Found $FILTERED_COUNT issues from Dec 15 onwards on page $page"
|
||||
|
||||
# Append to temp file
|
||||
CURRENT=$(cat "$TEMP_FILE")
|
||||
MERGED=$(echo "$CURRENT" "$FILTERED" | jq -s '.[0] + .[1]')
|
||||
echo "$MERGED" > "$TEMP_FILE"
|
||||
|
||||
# If we've started getting old data, we can stop
|
||||
OLDEST=$(echo "$PAGE_DATA" | jq -r '.[-1].created_at')
|
||||
if [[ "$OLDEST" < "$START_DATE" ]]; then
|
||||
echo " Reached data older than Dec 15, stopping"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# Create Python analysis script
|
||||
PYTHON_SCRIPT=$(mktemp)
|
||||
trap "rm -f $PYTHON_SCRIPT $TEMP_FILE" EXIT
|
||||
|
||||
cat > "$PYTHON_SCRIPT" << 'EOF'
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
# Read the issues data from file
|
||||
with open(sys.argv[1], 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not data:
|
||||
print("No issues found from Dec 15 onwards")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Analyzing {len(data)} issues...\n")
|
||||
|
||||
# Categorize and group by week
|
||||
issues_by_week = defaultdict(lambda: defaultdict(int))
|
||||
week_totals = defaultdict(int)
|
||||
week_order = []
|
||||
|
||||
# Response tracking
|
||||
response_by_week = defaultdict(lambda: {
|
||||
'total': 0,
|
||||
'with_response': 0,
|
||||
'no_response': 0
|
||||
})
|
||||
|
||||
def get_week_label(date_str):
|
||||
"""Convert date to week label"""
|
||||
date = datetime.fromisoformat(date_str.replace('Z', '+00:00')).replace(tzinfo=None)
|
||||
|
||||
# Manual week grouping for clarity
|
||||
if date >= datetime(2025, 12, 22):
|
||||
return "Week 51: Dec 22-26"
|
||||
elif date >= datetime(2025, 12, 15):
|
||||
return "Week 50: Dec 15-21"
|
||||
else:
|
||||
return "Earlier"
|
||||
|
||||
def categorize_issue(item):
|
||||
"""Categorize an issue"""
|
||||
if item.get('pull_request'):
|
||||
return "PR"
|
||||
|
||||
labels = [label['name'] for label in item.get('labels', [])]
|
||||
title = item['title'].lower()
|
||||
|
||||
if 'discussion' in labels:
|
||||
return "Feature Request"
|
||||
elif 'help-wanted' in labels:
|
||||
return "Help Question"
|
||||
elif 'bug' in labels:
|
||||
return "Bug Report"
|
||||
elif any(x in title for x in ['[feature]', 'feature request', '[feat]']):
|
||||
return "Feature Request"
|
||||
elif title.endswith('?') and 'bug' not in title:
|
||||
return "Help Question"
|
||||
else:
|
||||
return "Other"
|
||||
|
||||
# Process each issue
|
||||
for item in data:
|
||||
week_label = get_week_label(item['created_at'])
|
||||
if week_label not in week_order:
|
||||
week_order.append(week_label)
|
||||
|
||||
category = categorize_issue(item)
|
||||
|
||||
# Check if it's an actual issue (not PR)
|
||||
if not item.get('pull_request'):
|
||||
response_by_week[week_label]['total'] += 1
|
||||
if item['comments'] > 0:
|
||||
response_by_week[week_label]['with_response'] += 1
|
||||
else:
|
||||
response_by_week[week_label]['no_response'] += 1
|
||||
|
||||
issues_by_week[week_label][category] += 1
|
||||
week_totals[week_label] += 1
|
||||
|
||||
# Sort weeks (most recent first)
|
||||
week_order = sorted([w for w in week_order if w != "Earlier"], reverse=True)
|
||||
|
||||
# Print results
|
||||
print("="*80)
|
||||
print("GITHUB ISSUES BREAKDOWN - RECENT WEEKS")
|
||||
print("="*80 + "\n")
|
||||
|
||||
for week in week_order:
|
||||
print(f"{week}: {week_totals[week]} total")
|
||||
for category in sorted(issues_by_week[week].keys()):
|
||||
count = issues_by_week[week][category]
|
||||
print(f" • {category}: {count}")
|
||||
print()
|
||||
|
||||
print("---")
|
||||
total = sum(week_totals[w] for w in week_order)
|
||||
print(f"TOTAL: {total} issues/PRs\n")
|
||||
|
||||
print("OVERALL SUMMARY:")
|
||||
all_counts = defaultdict(int)
|
||||
for week in week_order:
|
||||
for category, count in issues_by_week[week].items():
|
||||
all_counts[category] += count
|
||||
|
||||
for category in sorted(all_counts.keys(), key=lambda x: -all_counts[x]):
|
||||
count = all_counts[category]
|
||||
pct = (count / total) * 100
|
||||
print(f" • {category}: {count} ({pct:.1f}%)")
|
||||
|
||||
# Response rates
|
||||
print("\n" + "="*80)
|
||||
print("ISSUE RESPONSE RATES")
|
||||
print("="*80 + "\n")
|
||||
|
||||
for week in week_order:
|
||||
data = response_by_week[week]
|
||||
if data['total'] > 0:
|
||||
rate = (data['with_response'] / data['total'] * 100)
|
||||
print(f"{week}:")
|
||||
print(f" Total issues: {data['total']}")
|
||||
print(f" With response: {data['with_response']} ({rate:.1f}%)")
|
||||
print(f" No response: {data['no_response']}")
|
||||
print()
|
||||
|
||||
# Week over week comparison
|
||||
print("="*80)
|
||||
print("WEEK-OVER-WEEK COMPARISON")
|
||||
print("="*80 + "\n")
|
||||
|
||||
if len(week_order) >= 2:
|
||||
w1 = week_order[0] # Most recent
|
||||
w2 = week_order[1] # Previous
|
||||
|
||||
vol_change = week_totals[w1] - week_totals[w2]
|
||||
vol_pct = (vol_change / week_totals[w2] * 100) if week_totals[w2] > 0 else 0
|
||||
|
||||
print(f"Volume Change: {week_totals[w2]} → {week_totals[w1]} ({vol_pct:+.1f}%)")
|
||||
print()
|
||||
|
||||
print("Category Changes:")
|
||||
for category in sorted(all_counts.keys()):
|
||||
old_val = issues_by_week[w2].get(category, 0)
|
||||
new_val = issues_by_week[w1].get(category, 0)
|
||||
change = new_val - old_val
|
||||
direction = "↑" if change > 0 else "↓" if change < 0 else "→"
|
||||
print(f" {category:18s}: {old_val:3d} → {new_val:3d} {direction} {abs(change)}")
|
||||
|
||||
print()
|
||||
if response_by_week[w1]['total'] > 0 and response_by_week[w2]['total'] > 0:
|
||||
r1 = (response_by_week[w1]['with_response'] / response_by_week[w1]['total'] * 100)
|
||||
r2 = (response_by_week[w2]['with_response'] / response_by_week[w2]['total'] * 100)
|
||||
print(f"Response Rate: {r2:.1f}% → {r1:.1f}% ({r1-r2:+.1f}pp)")
|
||||
|
||||
print("\n" + "="*80 + "\n")
|
||||
EOF
|
||||
|
||||
# Run the analysis
|
||||
python3 "$PYTHON_SCRIPT" "$TEMP_FILE"
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.0.212",
|
||||
"version": "1.0.219",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user