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 |
@@ -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.217",
|
||||
"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.217",
|
||||
"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.217",
|
||||
"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.217",
|
||||
"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.217",
|
||||
"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.217",
|
||||
"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.217",
|
||||
"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.217",
|
||||
"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.217",
|
||||
"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",
|
||||
@@ -347,7 +348,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.217",
|
||||
"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.217",
|
||||
"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.217",
|
||||
"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.217",
|
||||
"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.217",
|
||||
"version": "1.0.219",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -440,7 +441,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.0.217",
|
||||
"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=="],
|
||||
@@ -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=="],
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"nodeModules": "sha256-2Wbnxy9SPcZkO03Sis3uiypPXa87jc5TzKbo6PvMlxY="
|
||||
"nodeModules": "sha256-7zMUWgMCnoe2As8WdEKazkKiGEcUIk5rP4zFvX9USgA="
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.0.217",
|
||||
"version": "1.0.219",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1126,6 +1126,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const variant = local.model.variant.current()
|
||||
|
||||
if (isShellMode) {
|
||||
sdk.client.session
|
||||
@@ -1153,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)
|
||||
@@ -1189,6 +1191,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
model,
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
variant,
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to send prompt", e)
|
||||
@@ -1375,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>
|
||||
}
|
||||
>
|
||||
@@ -1391,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>
|
||||
|
||||
@@ -115,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: {},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -272,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])
|
||||
},
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
|
||||
@@ -117,10 +117,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
|
||||
const origin = createMemo(() => projectsKey(active()))
|
||||
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
||||
const isLocal = createMemo(() => origin() === "local")
|
||||
|
||||
return {
|
||||
ready: isReady,
|
||||
healthy,
|
||||
isLocal,
|
||||
get url() {
|
||||
return active()
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.217",
|
||||
"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.217",
|
||||
"version": "1.0.219",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.217",
|
||||
"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.217",
|
||||
"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.217",
|
||||
"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.217",
|
||||
"version": "1.0.219",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.0.217"
|
||||
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.217/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.217/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.217/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.217/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.217/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.217",
|
||||
"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.217",
|
||||
"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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -111,12 +111,16 @@ export namespace Server {
|
||||
|
||||
if (input.startsWith("http://localhost:")) return input
|
||||
if (input.startsWith("http://127.0.0.1:")) return input
|
||||
if (input === "tauri://localhost") 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
|
||||
},
|
||||
}),
|
||||
@@ -2676,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,
|
||||
@@ -2702,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 ""
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.217",
|
||||
"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.217",
|
||||
"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.217",
|
||||
"version": "1.0.219",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.217",
|
||||
"version": "1.0.219",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./src/components/*.tsx",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.217",
|
||||
"version": "1.0.219",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"version": "1.0.217",
|
||||
"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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.217",
|
||||
"version": "1.0.219",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user