Compare commits

...
Author SHA1 Message Date
Dax Raad 50e17b7f95 fix(desktop): use OpenCode-hosted update releases 2026-09-08 00:04:10 -04:00
Aiden Cline e8177238f6 feat(core): support native Snowflake Cortex authentication (#47156) 2026-09-07 22:50:50 -05:00
Aiden Cline b3f36c0967 feat(ai): add Z.AI language models (#47866) 2026-09-07 21:55:33 -05:00
Kit Langton 1f77408ff2 feat(tui): navigate projects and worktrees
Add project and worktree navigation with restored search and selection, workspace-preserving targets, and optional worktree naming. Keep creation in the Ctrl+N footer and defer filesystem browsing.
2026-09-07 22:35:52 -04:00
Aiden Cline a912a6ee4f fix(core): clarify shell background parameter guidance (#47865) 2026-09-07 20:16:18 -05:00
Aiden Cline 2ac698d65a feat(ai): add Moonshot provider (#47851) 2026-09-07 19:50:55 -05:00
opencode-agent[bot] c1c6ab593d chore: update nix node_modules hashes 2026-09-07 23:40:48 +00:00
Dax Raad d1d1c6f890 feat(release): publish package binaries through Cloudflare
Move files deployments to Wrangler, make CLI and desktop own their publishing destinations, add direct-download update metadata and desktop feeds, and refresh installation docs.
2026-09-07 19:17:29 -04:00
opencode-agent[bot] 0ab661a9cc chore: update nix node_modules hashes 2026-09-07 22:53:49 +00:00
Dax Raad a55dc8c84a feat(services): organize hosted services and add public files 2026-09-07 18:34:34 -04:00
207 changed files with 6009 additions and 568 deletions
+34
View File
@@ -0,0 +1,34 @@
name: deploy-files
on:
push:
branches:
- dev
- v2
workflow_dispatch:
concurrency:
group: deploy-files-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'v2')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Typecheck
working-directory: services/files
run: bun typecheck
- name: Deploy
working-directory: services/files
run: bun run deploy --env ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+2 -2
View File
@@ -24,13 +24,13 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: packages/www
working-directory: services/www
run: bun run build
env:
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
- name: Deploy
working-directory: packages/www
working-directory: services/www
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1
View File
@@ -11,6 +11,7 @@ on:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
- "services/*/package.json"
- "flake.lock"
- "nix/node_modules.nix"
- "nix/scripts/**"
+4 -14
View File
@@ -48,7 +48,7 @@ jobs:
- name: Deploy update service
if: github.ref_name == 'v2'
working-directory: packages/updates
working-directory: services/updates
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@@ -670,19 +670,6 @@ jobs:
git config --global user.name "opencode"
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
- name: Upload desktop release assets
if: needs.version.outputs.release
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
shopt -s nullglob
files=(/tmp/desktop/*.{exe,blockmap,dmg,zip,AppImage,deb,rpm} /tmp/desktop/*.app.tar.gz)
if (( ${#files[@]} == 0 )); then
echo "No desktop release assets found"
exit 1
fi
gh release upload "v${{ needs.version.outputs.version }}" "${files[@]}" --clobber --repo "${{ needs.version.outputs.repo }}"
- run: ./script/publish.ts
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
@@ -694,3 +681,6 @@ jobs:
LATEST_YML_DIR: /tmp/latest-yml
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
OPENCODE_DESKTOP_DIST: /tmp/desktop
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -1
View File
@@ -173,7 +173,7 @@ jobs:
- name: Check generated documentation
if: runner.os == 'Linux'
working-directory: packages/www
working-directory: services/www
run: bun run check:generated
e2e:
+159 -19
View File
@@ -962,22 +962,6 @@
"solid-js": "^1.9.8",
},
},
"packages/updates": {
"name": "@opencode/updates",
"version": "1.18.4",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"wrangler": "4.110.0",
},
},
"packages/util": {
"name": "@opencode/util",
"version": "1.18.3",
@@ -1043,7 +1027,33 @@
"typescript": "catalog:",
},
},
"packages/www": {
"services/files": {
"name": "@opencode/files",
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "catalog:",
"@typescript/native-preview": "catalog:",
"wrangler": "4.110.0",
},
},
"services/updates": {
"name": "@opencode/updates",
"version": "1.18.4",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
"yaml": "2.9.0",
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"wrangler": "4.110.0",
},
},
"services/www": {
"name": "@opencode/www",
"dependencies": {
"@astrojs/mdx": "7.0.5",
@@ -2188,6 +2198,8 @@
"@opencode/enterprise": ["@opencode/enterprise@workspace:packages/enterprise"],
"@opencode/files": ["@opencode/files@workspace:services/files"],
"@opencode/function": ["@opencode/function@workspace:packages/function"],
"@opencode/http-recorder": ["@opencode/http-recorder@workspace:packages/http-recorder"],
@@ -2232,13 +2244,13 @@
"@opencode/ui": ["@opencode/ui@workspace:packages/ui"],
"@opencode/updates": ["@opencode/updates@workspace:packages/updates"],
"@opencode/updates": ["@opencode/updates@workspace:services/updates"],
"@opencode/util": ["@opencode/util@workspace:packages/util"],
"@opencode/web": ["@opencode/web@workspace:packages/web"],
"@opencode/www": ["@opencode/www@workspace:packages/www"],
"@opencode/www": ["@opencode/www@workspace:services/www"],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.10.0", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-FMsAEjLUt5pWuRE2LDC/LCvVrFjLlrEzUITH5+5SZtfq7KZ2wrOHjQVxzz92sju8S9ltpzW87CLW8/b0oBXVCw=="],
@@ -6378,6 +6390,8 @@
"@opencode/enterprise/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "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-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
"@opencode/files/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
"@opencode/posts/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
"@opencode/session-ui/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "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-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
@@ -7272,6 +7286,16 @@
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.11", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.11", "@tailwindcss/oxide-darwin-arm64": "4.1.11", "@tailwindcss/oxide-darwin-x64": "4.1.11", "@tailwindcss/oxide-freebsd-x64": "4.1.11", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", "@tailwindcss/oxide-linux-x64-musl": "4.1.11", "@tailwindcss/oxide-wasm32-wasi": "4.1.11", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" } }, "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg=="],
"@opencode/files/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
"@opencode/files/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
"@opencode/files/wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@opencode/files/wrangler/miniflare": ["miniflare@4.20260708.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260708.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA=="],
"@opencode/files/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode/posts/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
"@opencode/posts/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
@@ -8066,6 +8090,72 @@
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.11", "", { "os": "win32", "cpu": "x64" }, "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg=="],
"@opencode/files/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@opencode/files/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@opencode/files/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@opencode/files/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@opencode/files/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@opencode/files/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@opencode/files/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@opencode/files/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@opencode/files/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@opencode/files/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@opencode/files/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@opencode/files/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@opencode/files/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@opencode/files/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@opencode/files/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@opencode/files/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@opencode/files/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@opencode/files/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@opencode/files/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@opencode/files/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@opencode/files/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@opencode/files/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@opencode/files/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@opencode/files/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@opencode/files/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@opencode/files/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@opencode/files/wrangler/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"@opencode/files/wrangler/miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"@opencode/files/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
"@opencode/files/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
"@opencode/files/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260708.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA=="],
"@opencode/files/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260708.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA=="],
"@opencode/files/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
"@opencode/posts/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@opencode/posts/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
@@ -8698,6 +8788,54 @@
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@opencode/posts/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@opencode/posts/wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
@@ -9004,6 +9142,8 @@
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@opencode/files/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@opencode/posts/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@opencode/storybook/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-+E2chSxD9x139rXmeomoJMiNybSAAZstB2UiOBst8nE=",
"aarch64-linux": "sha256-Wsm3Q4+k1kzxP0aBv2MRJl2sW/q/7CD9GuWsd4J7xEo=",
"aarch64-darwin": "sha256-6lrSRpyGrN0FmQ3dXSaUERUQyW4IxjXCCeWi5oJ6GyM=",
"x86_64-darwin": "sha256-V7XSss7r0hkUsH92rtJhwuWEZXnemridoF6wCTbl8Lc="
"x86_64-linux": "sha256-gW+1JbPQnt+PZzHfGijjnwch6X6pyaBnPTxAc47yiQw=",
"aarch64-linux": "sha256-qI6CtJkmh20ouWeiQRR1zp+YjHootkN23VFJCwqnf0A=",
"aarch64-darwin": "sha256-oyEhVcjbKq3+BCCxVBl0TZYmKJv1BBLWPVs0Vw6Fbac=",
"x86_64-darwin": "sha256-UhLZjG3NlkoXRWNn8SK/pLm5eDR3saU+TgEgfX592vQ="
}
}
+1
View File
@@ -27,6 +27,7 @@ stdenvNoCC.mkDerivation {
fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) (
lib.fileset.unions [
../packages
../services
../bun.lock
../package.json
../patches
+2 -1
View File
@@ -13,7 +13,7 @@
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:www": "bun run --cwd packages/www dev",
"dev:www": "bun run --cwd services/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"bench:devex": "bun run --cwd packages/app test:bench:devex",
"lint": "oxlint",
@@ -34,6 +34,7 @@
"workspaces": {
"packages": [
"packages/*",
"services/*",
"packages/console/*",
"packages/stats/*"
],
+1
View File
@@ -13,6 +13,7 @@
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
- Order reasoning-effort values from lowest to highest: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Provider-specific subsets follow the same relative order in types, schemas, option lists, and tests.
## Tests
+128
View File
@@ -29,6 +29,134 @@ await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
## Z.AI
`ZAI` uses the standard API. Chat Completions is the default language-model API;
the existing `.image(...)` selector provides image generation.
```ts
import { LLM } from "@opencode/ai"
import { ZAI, ZAICodingPlan } from "@opencode/ai/providers"
const zai = ZAI.configure({ apiKey: process.env.ZAI_API_KEY })
const request = LLM.request({
model: zai.model("glm-5.3"), // also zai.chat("glm-5.3")
prompt: "Explain this design.",
providerOptions: {
reasoningEffort: "high",
thinking: { type: "enabled", clear_thinking: false },
},
})
const coding = ZAICodingPlan.configure({ apiKey: process.env.ZAI_API_KEY })
const messages = LLM.request({
model: coding.messages("glm-5.3"),
prompt: "Explain this design.",
providerOptions: { effort: "high" },
})
```
The products have distinct provider identities and endpoints:
| Provider | Selector | Default base URL |
| ----------------------------------- | --------------------------- | ------------------------------------- |
| `ZAI` (`zai`) | `.model`, `.chat`, `.image` | `https://api.z.ai/api/paas/v4` |
| `ZAICodingPlan` (`zai-coding-plan`) | `.model`, `.chat` | `https://api.z.ai/api/coding/paas/v4` |
| `ZAICodingPlan` | `.messages` | `https://api.z.ai/api/anthropic/v1` |
| `ZAICodingPlan` | `.responses` | `https://api.z.ai/api/v1` |
Both read `ZAI_API_KEY` when `apiKey` is omitted and support an explicit `auth` override.
Coding Plan requires an active subscription. `baseURL` overrides the selected API's
complete base, including its version prefix. Language-model routes use HTTP/SSE.
Options retain the selected API's native semantics:
- Chat `reasoningEffort` lowers to `reasoning_effort`; Responses lowers it to `reasoning.effort`.
Messages `effort` lowers to `output_config.effort`. Omission preserves provider defaults.
- Chat `thinking` passes `type` and `clear_thinking` through unchanged. Set
`clear_thinking: false` and replay complete `response.message` values to preserve reasoning
across user messages and tool loops. The standard API defaults to clearing historical thinking;
Coding Plan documents preservation by default.
- Messages accepts `thinking: { type: "enabled" | "adaptive" | "disabled" }` without requiring
an Anthropic token budget. Coding Plan documents a disabled toggle as low-effort thinking
for GLM-5.3, with explicit effort taking precedence.
- Chat also offers `toolStream`, `doSample`, `responseFormat`, `requestID`, and `userID`.
Tool-argument streaming is enabled when tools are present on GLM-4.6/4.7/5.x;
`toolStream: false` explicitly disables it. Older model families omit the opt-in.
- Effort and thinking values remain forward-compatible strings. Their meaning is model-specific:
GLM-5.3 accepts `low`, `high`, and `max` effort and rejects disabled thinking with HTTP 400;
the direct GLM-5.2 recordings returned reasoning even with `none` and `minimal` effort,
whereas explicit `thinking.type: "disabled"` disabled it on GLM-5.2 and GLM-4.7.
Standard API recordings cover GLM-5.3 efforts and a full preserved-reasoning tool loop with
a subsequent user follow-up, GLM-5.2 efforts, older-model thinking toggles, GLM-4.5 tool calls,
GLM-5.3-Flash image input, and JSON output. Coding Plan has unit coverage for routing,
request options, and reasoning replay; successful live recordings are pending.
Package entrypoints are `@opencode/ai/providers/zai`, `zai/chat`, `zai-coding-plan`,
`zai-coding-plan/chat`, `zai-coding-plan/messages`, and `zai-coding-plan/responses`.
## Moonshot
Moonshot defaults to Chat Completions, with Messages and Responses selectors for Kimi K3:
```ts
import { LLM } from "@opencode/ai"
import { Moonshot } from "@opencode/ai/providers"
const moonshot = Moonshot.configure({ apiKey: process.env.MOONSHOT_API_KEY })
const request = LLM.request({
model: moonshot.model("kimi-k3"), // also moonshot.chat("kimi-k3")
prompt: "Explain the tradeoffs in this design.",
providerOptions: { reasoningEffort: "high" },
})
const messages = LLM.request({
model: moonshot.messages("kimi-k3"),
prompt: "Explain the tradeoffs in this design.",
providerOptions: { effort: "high" },
})
const responses = LLM.request({
model: moonshot.responses("kimi-k3"),
prompt: "Explain the tradeoffs in this design.",
providerOptions: { reasoningEffort: "high" },
})
```
When `apiKey` is omitted, authentication reads `MOONSHOT_API_KEY`, then `MOONSHOTAI_API_KEY`.
Chat and Responses use `https://api.moonshot.ai/v1`; Messages uses
`https://api.moonshot.ai/anthropic/v1`. `baseURL` overrides the selected API's complete base,
including the version prefix, for regional endpoints or gateways. Each endpoint requires its own valid credentials.
All three routes use HTTP/SSE.
Reasoning options stay native to the selected API and model:
| Model/API | Provider options |
| --------------------------- | --------------------------------------------------------------------------------------- |
| K3 Chat / Responses | `reasoningEffort: "low" \| "high" \| "max"`; default is `max` |
| K3 Messages | `effort: "low" \| "high" \| "max"`; default is `max` |
| K2.6 Chat | `thinking: { type: "enabled" \| "disabled", keep?: "all" \| null }`; default is enabled |
| K2.7 Code / high-speed Chat | Omit `thinking` to use always-on, preserved reasoning |
Omitting options preserves the model's defaults. K3 uses effort rather than the K2.x `thinking`
parameter. Known effort values have autocomplete while future strings remain accepted.
For K2.6, `thinking.keep: "all"` enables preservation of reasoning across user messages.
K3 and both K2.7 Code variants always preserve reasoning. Continue with the returned
`response.message` and matching tool results so reasoning content and any Messages signatures are retained.
Leave sampling options such as `temperature` unset to use these models' fixed defaults.
The recorded suite covers all three K3 APIs, default and explicit efforts, K2.6 thinking modes,
both K2.7 Code variants, generated tool loops with a subsequent user follow-up, required/disabled
tool choice, image-byte input, and native structured output through `http.body` overlays.
K3 Chat and Messages accept required and disabled tool choice. Responses supports automatic tool
choice only; explicit `required` and `none` produce a provider `InvalidRequest` error, also covered by recordings.
The provider targets the Moonshot Open Platform; Kimi Code is a separate product and endpoint.
Package entrypoints are `@opencode/ai/providers/moonshot`, `moonshot/chat`, `moonshot/messages`,
and `moonshot/responses`; each exports `model(modelID, settings)`.
## MiniMax
MiniMax defaults to its Messages API and reads `MINIMAX_API_KEY` when `apiKey` is omitted:
+75
View File
@@ -0,0 +1,75 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import type { LanguageModelCompatibility, LLMRequest } from "../schema/index.js"
import { OpenAIChat } from "./openai-chat.js"
import { ProviderShared } from "./shared.js"
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | (string & {})
export type OptionsInput = {
readonly reasoningEffort?: ReasoningEffort
readonly thinking?: {
readonly type?: "enabled" | "disabled" | (string & {})
/** False retains historical reasoning; omission preserves the endpoint's default. */
readonly clear_thinking?: boolean
}
readonly toolStream?: boolean
readonly doSample?: boolean
readonly responseFormat?: { readonly type: "text" | "json_object" | (string & {}) }
readonly requestID?: string
readonly userID?: string
}
const Options = Schema.Struct({
reasoningEffort: Schema.optional(Schema.String),
thinking: Schema.optional(
Schema.Struct({ type: Schema.optional(Schema.String), clear_thinking: Schema.optional(Schema.Boolean) }),
),
toolStream: Schema.optional(Schema.Boolean),
doSample: Schema.optional(Schema.Boolean),
responseFormat: Schema.optional(Schema.Struct({ type: Schema.String })),
requestID: Schema.optional(Schema.String),
userID: Schema.optional(Schema.String),
})
const Body = Schema.Struct({
...OpenAIChat.bodyFields,
thinking: Options.fields.thinking,
do_sample: Options.fields.doSample,
response_format: Options.fields.responseFormat,
request_id: Options.fields.requestID,
user_id: Options.fields.userID,
})
const fromRequest = Effect.fn("ZAIChat.fromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
const body = yield* OpenAIChat.protocol.body.from(request)
return {
...body,
thinking: options.thinking,
// Tool streaming was introduced in GLM-4.6; older models must not receive the opt-in.
tool_stream:
options.toolStream ??
(body.tools?.length && /^glm-(?:4\.[67]|5(?:[.-]|$))/i.test(request.model.id) ? true : undefined),
do_sample: options.doSample,
response_format: options.responseFormat,
request_id: options.requestID,
user_id: options.userID,
}
})
export const compatibility = {
maxTokensField: "max_tokens",
supportsStore: false,
supportsStrictMode: false,
reasoningField: "reasoning_content",
zaiToolStream: false,
} satisfies LanguageModelCompatibility
export const protocol = Protocol.make({
id: "zai-chat",
body: { schema: Body, from: fromRequest },
stream: OpenAIChat.protocol.stream,
})
export * as ZAIChat from "./zai-chat.js"
+39
View File
@@ -0,0 +1,39 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import { LLMRequest } from "../schema/index.js"
import { AnthropicMessages } from "./anthropic-messages.js"
import { ProviderShared } from "./shared.js"
import type { ZAIChat } from "./zai-chat.js"
export type OptionsInput = {
readonly effort?: ZAIChat.ReasoningEffort
readonly thinking?: { readonly type: "enabled" | "adaptive" | "disabled" | (string & {}) }
}
const Options = Schema.Struct({
effort: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.Struct({ type: Schema.String })),
})
const Body = Schema.Struct({
...AnthropicMessages.AnthropicMessagesBody.fields,
thinking: Options.fields.thinking,
})
const fromRequest = Effect.fn("ZAIMessages.fromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
// Z.AI accepts enabled thinking without Anthropic's mandatory token budget.
const body = yield* AnthropicMessages.protocol.body.from(
LLMRequest.update(request, {
providerOptions: { ...request.providerOptions, thinking: undefined },
}),
)
return { ...body, thinking: options.thinking }
})
export const protocol = Protocol.make({
id: "zai-messages",
body: { schema: Body, from: fromRequest },
stream: AnthropicMessages.protocol.stream,
})
export * as ZAIMessages from "./zai-messages.js"
+2
View File
@@ -19,6 +19,7 @@ export * as Groq from "./groq.js"
export * as Meta from "./meta.js"
export * as MiniMax from "./minimax.js"
export * as Mistral from "./mistral.js"
export * as Moonshot from "./moonshot.js"
export * as OpenAI from "./openai.js"
export * as OpenAICompatible from "./openai-compatible.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
@@ -26,3 +27,4 @@ export * as OpenRouter from "./openrouter.js"
export * as TogetherAI from "./togetherai.js"
export * as XAI from "./xai.js"
export * as ZAI from "./zai.js"
export * as ZAICodingPlan from "./zai-coding-plan.js"
+145
View File
@@ -0,0 +1,145 @@
import { Effect, Schema } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { ProviderShared } from "../protocols/shared.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("moonshotai")
export type ReasoningEffort = "low" | "high" | "max" | (string & {})
export type ChatOptionsInput = {
/** K3 always reasons; omitted effort uses the model's default. */
readonly reasoningEffort?: ReasoningEffort
/** K2.6 supports disabling thinking; K2.7 Code always thinks and preserves reasoning. */
readonly thinking?: {
readonly type: "enabled" | "disabled" | (string & {})
readonly keep?: "all" | (string & {}) | null
}
}
export type MessagesOptionsInput = {
readonly effort?: ReasoningEffort
readonly metadata?: AnthropicMessages.OptionsInput["metadata"]
}
export type ResponsesOptionsInput = {
readonly reasoningEffort?: ReasoningEffort
readonly safetyIdentifier?: string
}
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
/** Overrides the selected API's base URL, including its version prefix. */
readonly baseURL?: string
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
const ChatOptions = Schema.Struct({
reasoningEffort: Schema.optional(Schema.String),
thinking: Schema.optional(
Schema.Struct({ type: Schema.String, keep: Schema.optional(Schema.NullOr(Schema.String)) }),
),
})
const chatProtocol = Protocol.make({
id: "moonshot-chat",
body: {
schema: Schema.Struct({ ...OpenAIChat.bodyFields, thinking: ChatOptions.fields.thinking }),
from: Effect.fn("Moonshot.chatFromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(ChatOptions))(
request.providerOptions ?? {},
)
return { ...(yield* OpenAIChat.protocol.body.from(request)), thinking: options.thinking }
}),
},
stream: OpenAIChat.protocol.stream,
})
const chatRoute = Route.make({
id: "moonshot-chat",
provider: id,
providerMetadataKey: "moonshot",
protocol: chatProtocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.moonshot.ai/v1" }),
framing: OpenAIChat.framing,
})
const messagesRoute = Route.make({
id: "moonshot-messages",
provider: id,
providerMetadataKey: "moonshot",
protocol: AnthropicMessages.protocol,
endpoint: Endpoint.path("/messages", { baseURL: "https://api.moonshot.ai/anthropic/v1" }),
framing: AnthropicMessages.framing,
})
const responsesRoute = Route.make({
id: "moonshot-responses",
provider: id,
providerMetadataKey: "moonshot",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: "https://api.moonshot.ai/v1" }),
framing: Framing.sse,
})
export const routes = [chatRoute, messagesRoute, responsesRoute]
export const configure = (input: Config = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...rest } = input
const defaults = {
...rest,
endpoint: baseURL === undefined ? undefined : { baseURL },
auth: AuthOptions.bearer(input, ["MOONSHOT_API_KEY", "MOONSHOTAI_API_KEY"]),
}
const chat = (modelID: string | ModelID) =>
chatRoute.with(defaults).model<ChatOptionsInput>({
id: modelID,
compatibility: {
maxTokensField: "max_tokens",
supportsStore: false,
supportsStrictMode: false,
toolSchema: "moonshot",
reasoningField: "reasoning_content",
},
})
const messages = (modelID: string | ModelID) =>
messagesRoute.with(defaults).model<MessagesOptionsInput>({
id: modelID,
compatibility: { requireSignature: false, toolSchema: "moonshot" },
})
const responses = (modelID: string | ModelID) =>
responsesRoute
.with(defaults)
.model<ResponsesOptionsInput>({ id: modelID, compatibility: { toolSchema: "moonshot" } })
return { id, model: chat, chat, messages, responses, configure }
}
export const provider = configure()
export const chat = provider.chat
export const messages = provider.messages
export const responses = provider.responses
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as Moonshot from "./moonshot.js"
@@ -0,0 +1 @@
export { model, type Settings } from "../moonshot.js"
@@ -0,0 +1,16 @@
import type { ProviderPackage } from "../../provider-package.js"
import { Moonshot } from "../moonshot.js"
export type Settings = Moonshot.Settings<Moonshot.MessagesOptionsInput>
export const model: ProviderPackage.Definition<Settings, Moonshot.MessagesOptionsInput>["model"] = (
modelID,
settings,
) =>
Moonshot.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).messages(modelID)
@@ -0,0 +1,16 @@
import type { ProviderPackage } from "../../provider-package.js"
import { Moonshot } from "../moonshot.js"
export type Settings = Moonshot.Settings<Moonshot.ResponsesOptionsInput>
export const model: ProviderPackage.Definition<Settings, Moonshot.ResponsesOptionsInput>["model"] = (
modelID,
settings,
) =>
Moonshot.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).responses(modelID)
@@ -0,0 +1,92 @@
import type { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { ZAIChat } from "../protocols/zai-chat.js"
import { ZAIMessages } from "../protocols/zai-messages.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderID, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("zai-coding-plan")
export type ChatOptionsInput = ZAIChat.OptionsInput
export type MessagesOptionsInput = ZAIMessages.OptionsInput
export type ResponsesOptionsInput = { readonly reasoningEffort?: ZAIChat.ReasoningEffort }
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
/** Overrides the selected API's complete base URL. */
readonly baseURL?: string
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
const chatRoute = Route.make({
id: "zai-coding-chat",
provider: id,
providerMetadataKey: "zai",
protocol: ZAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.z.ai/api/coding/paas/v4" }),
framing: OpenAIChat.framing,
})
const messagesRoute = Route.make({
id: "zai-coding-messages",
provider: id,
providerMetadataKey: "zai",
protocol: ZAIMessages.protocol,
endpoint: Endpoint.path("/messages", { baseURL: "https://api.z.ai/api/anthropic/v1" }),
framing: AnthropicMessages.framing,
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
const responsesRoute = Route.make({
id: "zai-coding-responses",
provider: id,
providerMetadataKey: "zai",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: "https://api.z.ai/api/v1" }),
framing: Framing.sse,
})
export const routes = [chatRoute, messagesRoute, responsesRoute]
export const configure = (input: Config = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...rest } = input
const defaults = {
...rest,
endpoint: baseURL === undefined ? undefined : { baseURL },
auth: AuthOptions.bearer(input, "ZAI_API_KEY"),
}
const chat = (modelID: string | ModelID) =>
chatRoute.with(defaults).model<ChatOptionsInput>({ id: modelID, compatibility: ZAIChat.compatibility })
const messages = (modelID: string | ModelID) =>
messagesRoute
.with(defaults)
.model<MessagesOptionsInput>({ id: modelID, compatibility: { requireSignature: false } })
const responses = (modelID: string | ModelID) =>
responsesRoute.with(defaults).model<ResponsesOptionsInput>({ id: modelID })
return { id, model: chat, chat, messages, responses, configure }
}
export const provider = configure()
export const chat = provider.chat
export const messages = provider.messages
export const responses = provider.responses
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as ZAICodingPlan from "./zai-coding-plan.js"
@@ -0,0 +1 @@
export { model, type Settings } from "../zai-coding-plan.js"
@@ -0,0 +1,16 @@
import type { ProviderPackage } from "../../provider-package.js"
import { ZAICodingPlan } from "../zai-coding-plan.js"
export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.MessagesOptionsInput>
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.MessagesOptionsInput>["model"] = (
modelID,
settings,
) =>
ZAICodingPlan.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).messages(modelID)
@@ -0,0 +1,16 @@
import type { ProviderPackage } from "../../provider-package.js"
import { ZAICodingPlan } from "../zai-coding-plan.js"
export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.ResponsesOptionsInput>
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.ResponsesOptionsInput>["model"] = (
modelID,
settings,
) =>
ZAICodingPlan.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).responses(modelID)
+50 -3
View File
@@ -1,20 +1,53 @@
import type { ProviderPackage } from "../provider-package.js"
import { ZAIChat } from "../protocols/zai-chat.js"
import { ZAIImages } from "../protocols/zai-images.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("zai")
export type Config = ProviderAuthOption<"optional"> & {
export type ChatOptionsInput = ZAIChat.OptionsInput
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: ChatOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly http?: HttpOptions.Input
readonly providerOptions?: ChatOptionsInput
}
export type { ZAIImageOptions } from "../protocols/zai-images.js"
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "ZAI_API_KEY")
const chatRoute = Route.make({
id: "zai-chat",
provider: id,
providerMetadataKey: "zai",
protocol: ZAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.z.ai/api/paas/v4" }),
framing: OpenAIChat.framing,
})
export const routes = [chatRoute]
export const configure = (input: Config = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...rest } = input
const chat = (modelID: string | ModelID) =>
chatRoute
.with({
...rest,
endpoint: baseURL === undefined ? undefined : { baseURL },
auth: auth(input),
})
.model<ChatOptionsInput>({ id: modelID, compatibility: ZAIChat.compatibility })
const image = (modelID: string | ModelID) =>
ZAIImages.model({
id: modelID,
@@ -26,6 +59,8 @@ export const configure = (input: Config = {}) => {
return {
id,
model: chat,
chat,
image,
configure,
}
@@ -33,3 +68,15 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
export const image = provider.image
export const chat = provider.chat
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as ZAI from "./zai.js"
+1
View File
@@ -0,0 +1 @@
export { model, type Settings } from "../zai.js"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
{
"version": 1,
"metadata": {
"model": "kimi-k2.6",
"tags": ["prefix:moonshot-chat", "provider:moonshot", "protocol:moonshot-chat", "text", "usage", "thinking-off"],
"name": "moonshot-chat/kimi-k2-6-streams-text-with-disabled-thinking",
"recordedAt": "2026-09-07T20:35:09.335Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.moonshot.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"kimi-k2.6\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"id\":\"chatcmpl-6a9f1ffd7d28eaca53fe9b4c\",\"object\":\"chat.completion.chunk\",\"created\":1788813309,\"model\":\"kimi-k2.6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}],\"system_fingerprint\":\"fpv0_45f9e909\"}\n\ndata: {\"id\":\"chatcmpl-6a9f1ffd7d28eaca53fe9b4c\",\"object\":\"chat.completion.chunk\",\"created\":1788813309,\"model\":\"kimi-k2.6\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\"},\"finish_reason\":null}],\"system_fingerprint\":\"fpv0_45f9e909\"}\n\ndata: {\"id\":\"chatcmpl-6a9f1ffd7d28eaca53fe9b4c\",\"object\":\"chat.completion.chunk\",\"created\":1788813309,\"model\":\"kimi-k2.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"378\"},\"finish_reason\":null}],\"system_fingerprint\":\"fpv0_45f9e909\"}\n\ndata: {\"id\":\"chatcmpl-6a9f1ffd7d28eaca53fe9b4c\",\"object\":\"chat.completion.chunk\",\"created\":1788813309,\"model\":\"kimi-k2.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"87\"},\"finish_reason\":null}],\"system_fingerprint\":\"fpv0_45f9e909\"}\n\ndata: {\"id\":\"chatcmpl-6a9f1ffd7d28eaca53fe9b4c\",\"object\":\"chat.completion.chunk\",\"created\":1788813309,\"model\":\"kimi-k2.6\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\",\"usage\":{\"prompt_tokens\":23,\"completion_tokens\":5,\"total_tokens\":28,\"completion_tokens_details\":{\"reasoning_tokens\":1}}}],\"system_fingerprint\":\"fpv0_45f9e909\"}\n\ndata: {\"id\":\"chatcmpl-6a9f1ffd7d28eaca53fe9b4c\",\"object\":\"chat.completion.chunk\",\"created\":1788813309,\"model\":\"kimi-k2.6\",\"choices\":[],\"usage\":{\"prompt_tokens\":23,\"completion_tokens\":5,\"total_tokens\":28,\"completion_tokens_details\":{\"reasoning_tokens\":1}}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
{
"version": 1,
"metadata": {
"model": "kimi-k3",
"tags": ["prefix:moonshot-messages", "provider:moonshot", "protocol:messages", "tool", "tool-choice", "usage"],
"name": "moonshot-messages/k3-respects-tool-choice-required",
"recordedAt": "2026-09-07T20:37:07.770Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.moonshot.ai/anthropic/v1/messages",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"kimi-k3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use get_weather to look up the current weather in Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"any\"},\"stream\":true,\"max_tokens\":4096,\"output_config\":{\"effort\":\"low\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"chatcmpl-2f2e02780e8a031b1b1307171\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"kimi-k3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":223,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\",\"prompt_tokens\":223,\"cached_tokens\":0}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Need\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" use\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" get\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"_we\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"ather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" for\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Call\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" function\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Then\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" final\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" summarize\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" result\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Use\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" tool\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"get_weather_0\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"city\\\": \\\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":223,\"output_tokens\":69,\"output_tokens_details\":{\"thinking_tokens\":19},\"prompt_tokens\":223,\"completion_tokens\":69,\"total_tokens\":292,\"cached_tokens\":223}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
{
"version": 1,
"metadata": {
"model": "kimi-k3",
"tags": ["prefix:moonshot-responses", "provider:moonshot", "protocol:responses", "tool", "tool-choice", "error"],
"name": "moonshot-responses/k3-rejects-unsupported-tool-choice-none",
"recordedAt": "2026-09-07T20:38:12.966Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.moonshot.ai/v1/responses",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"kimi-k3\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use get_weather to look up the current weather in Paris.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"tool_choice\":\"none\",\"reasoning\":{\"effort\":\"low\"},\"max_output_tokens\":4096,\"stream\":true}"
},
"response": {
"status": 400,
"headers": {
"content-type": "application/json"
},
"body": "{\"error\":{\"message\":\"invalid_request_error: responses: unsupported tool_choice value: \\\"none\\\"\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}\n"
}
}
]
}
@@ -0,0 +1,29 @@
{
"version": 1,
"metadata": {
"model": "kimi-k3",
"tags": ["prefix:moonshot-responses", "provider:moonshot", "protocol:responses", "tool", "tool-choice", "error"],
"name": "moonshot-responses/k3-rejects-unsupported-tool-choice-required",
"recordedAt": "2026-09-07T20:38:12.717Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.moonshot.ai/v1/responses",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"kimi-k3\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use get_weather to look up the current weather in Paris.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"tool_choice\":\"required\",\"reasoning\":{\"effort\":\"low\"},\"max_output_tokens\":4096,\"stream\":true}"
},
"response": {
"status": 400,
"headers": {
"content-type": "application/json"
},
"body": "{\"error\":{\"message\":\"invalid_request_error: responses: unsupported tool_choice value: \\\"required\\\"\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:zai-chat", "provider:zai", "protocol:zai-chat", "text", "thinking", "usage"],
"name": "zai-chat/glm-4-7-streams-thinking-disabled",
"recordedAt": "2026-09-08T00:59:30.888Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.z.ai/api/paas/v4/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"glm-4.7\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\",\"clear_thinking\":true}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream;charset=UTF-8"
},
"body": "data: {\"id\":\"20260908085928f9beb3885fd04e69\",\"created\":1788829168,\"object\":\"chat.completion.chunk\",\"model\":\"glm-4.7\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"3\"}}]}\n\ndata: {\"id\":\"20260908085928f9beb3885fd04e69\",\"created\":1788829168,\"object\":\"chat.completion.chunk\",\"model\":\"glm-4.7\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"7887\"}}]}\n\ndata: {\"id\":\"20260908085928f9beb3885fd04e69\",\"created\":1788829168,\"object\":\"chat.completion.chunk\",\"model\":\"glm-4.7\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}]}\n\ndata: {\"id\":\"20260908085928f9beb3885fd04e69\",\"created\":1788829168,\"object\":\"chat.completion.chunk\",\"model\":\"glm-4.7\",\"choices\":[{\"index\":0,\"finish_reason\":\"stop\",\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}],\"usage\":{\"prompt_tokens\":22,\"completion_tokens\":3,\"total_tokens\":25,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:zai-chat", "provider:zai", "protocol:zai-chat", "text", "thinking", "usage"],
"name": "zai-chat/glm-5-2-streams-thinking-disabled",
"recordedAt": "2026-09-08T00:59:35.300Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.z.ai/api/paas/v4/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"glm-5.2\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\",\"clear_thinking\":true}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream;charset=UTF-8"
},
"body": "data: {\"id\":\"20260908085934bc85f3b6601c42e1\",\"created\":1788829174,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"3\"}}]}\n\ndata: {\"id\":\"20260908085934bc85f3b6601c42e1\",\"created\":1788829174,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"7887\"}}]}\n\ndata: {\"id\":\"20260908085934bc85f3b6601c42e1\",\"created\":1788829174,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}]}\n\ndata: {\"id\":\"20260908085934bc85f3b6601c42e1\",\"created\":1788829174,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"finish_reason\":\"stop\",\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}],\"usage\":{\"prompt_tokens\":23,\"completion_tokens\":3,\"total_tokens\":26,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:zai-chat", "provider:zai", "protocol:zai-chat", "thinking", "model-specific", "error"],
"name": "zai-chat/glm-5-3-rejects-disabled-thinking",
"recordedAt": "2026-09-08T01:15:19.148Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.z.ai/api/paas/v4/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"glm-5.3\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\"}}"
},
"response": {
"status": 400,
"headers": {},
"body": "eyJlcnJvciI6eyJjb2RlIjoiMTIxMCIsIm1lc3NhZ2UiOiJUaGlzIG1vZGVsIGFsd2F5cyBlbmdhZ2VzIGluIHRoaW5raW5nIGFuZCBjYW5ub3QgYmUgZGlzYWJsZWQ7IHBsZWFzZSB1c2UgbG93LCBoaWdoLCBvciBtYXgifX0=",
"bodyEncoding": "base64"
}
}
]
}
@@ -0,0 +1,28 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:zai-chat", "provider:zai", "protocol:zai-chat", "structured-output"],
"name": "zai-chat/glm-5-3-returns-a-json-object",
"recordedAt": "2026-09-08T01:00:04.691Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.z.ai/api/paas/v4/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"glm-5.3\",\"messages\":[{\"role\":\"user\",\"content\":\"Return a JSON object with one key \\\"city\\\" set to the capital city of France.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"low\",\"max_tokens\":4096,\"response_format\":{\"type\":\"json_object\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream;charset=UTF-8"
},
"body": "data: {\"id\":\"20260908090004d403d46ed31e4758\",\"created\":1788829204,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.3\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\\n\"}}]}\n\ndata: {\"id\":\"20260908090004d403d46ed31e4758\",\"created\":1788829204,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.3\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"{\\\"\"}}]}\n\ndata: {\"id\":\"20260908090004d403d46ed31e4758\",\"created\":1788829204,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.3\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"city\"}}]}\n\ndata: {\"id\":\"20260908090004d403d46ed31e4758\",\"created\":1788829204,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.3\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\\\": \\\"Paris\\\"}\\n\"}}]}\n\ndata: {\"id\":\"20260908090004d403d46ed31e4758\",\"created\":1788829204,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.3\",\"choices\":[{\"index\":0,\"finish_reason\":\"stop\",\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}],\"usage\":{\"prompt_tokens\":91,\"completion_tokens\":12,\"total_tokens\":103,\"prompt_tokens_details\":{\"cached_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
import { LLM } from "../../src/index.js"
import { Moonshot } from "../../src/providers.js"
const moonshot = Moonshot.configure()
LLM.request({ model: moonshot.model("kimi-k2.6"), providerOptions: { thinking: { type: "enabled", keep: "all" } } })
LLM.request({ model: moonshot.chat("kimi-k2.6"), providerOptions: { thinking: { type: "disabled", keep: null } } })
LLM.request({ model: moonshot.chat("kimi-k3"), providerOptions: { reasoningEffort: "high" } })
LLM.request({
model: moonshot.messages("kimi-k3"),
providerOptions: { effort: "low", metadata: { user_id: "fixture" } },
})
LLM.request({ model: moonshot.responses("kimi-k3"), providerOptions: { reasoningEffort: "future-effort" } })
LLM.request({
model: moonshot.messages("kimi-k3"),
// @ts-expect-error Messages exposes effort rather than the K2 Chat thinking parameter.
providerOptions: { thinking: { type: "enabled" } },
})
LLM.request({
model: moonshot.chat("kimi-k2.6"),
// @ts-expect-error Preserved thinking takes a string or null.
providerOptions: { thinking: { type: "enabled", keep: true } },
})
LLM.request({
model: moonshot.responses("kimi-k3"),
// @ts-expect-error Responses uses reasoningEffort rather than the Messages effort option.
providerOptions: { effort: "high" },
})
@@ -0,0 +1,32 @@
import { LLM } from "../../src/index.js"
import { ZAI, ZAICodingPlan } from "../../src/providers.js"
LLM.request({
model: ZAI.chat("glm-5.3"),
providerOptions: { thinking: { clear_thinking: false }, reasoningEffort: "max" },
})
LLM.request({
model: ZAI.chat("glm-5.2"),
providerOptions: { thinking: { type: "disabled" }, toolStream: false, responseFormat: { type: "json_object" } },
})
LLM.request({
model: ZAICodingPlan.messages("glm-5.3"),
providerOptions: { thinking: { type: "enabled" }, effort: "high" },
})
LLM.request({ model: ZAICodingPlan.responses("glm-5.3"), providerOptions: { reasoningEffort: "future-effort" } })
LLM.request({
model: ZAI.chat("glm-5.3"),
// @ts-expect-error clear_thinking is a boolean.
providerOptions: { thinking: { clear_thinking: "false" } },
})
LLM.request({
model: ZAICodingPlan.messages("glm-5.3"),
// @ts-expect-error Messages uses effort.
providerOptions: { reasoningEffort: "high" },
})
LLM.request({
model: ZAICodingPlan.responses("glm-5.3"),
// @ts-expect-error Responses uses reasoning effort, not Chat thinking controls.
providerOptions: { thinking: { type: "enabled" } },
})
+70
View File
@@ -41,6 +41,16 @@ describe("provider package entrypoints", () => {
import("@opencode/ai/providers/minimax/messages"),
import("@opencode/ai/providers/minimax/chat"),
import("@opencode/ai/providers/minimax/responses"),
import("@opencode/ai/providers/moonshot"),
import("@opencode/ai/providers/moonshot/chat"),
import("@opencode/ai/providers/moonshot/messages"),
import("@opencode/ai/providers/moonshot/responses"),
import("@opencode/ai/providers/zai"),
import("@opencode/ai/providers/zai/chat"),
import("@opencode/ai/providers/zai-coding-plan"),
import("@opencode/ai/providers/zai-coding-plan/chat"),
import("@opencode/ai/providers/zai-coding-plan/messages"),
import("@opencode/ai/providers/zai-coding-plan/responses"),
])
for (const module of modules) expect(module.model).toBeFunction()
@@ -51,6 +61,31 @@ describe("provider package entrypoints", () => {
expect(modules[19].model).not.toBe(modules[20].model)
})
test("maps Moonshot API entrypoints onto provider-owned routes", async () => {
const modules = await Promise.all([
import("@opencode/ai/providers/moonshot"),
import("@opencode/ai/providers/moonshot/chat"),
import("@opencode/ai/providers/moonshot/messages"),
import("@opencode/ai/providers/moonshot/responses"),
])
expect(modules[0].model).toBe(modules[1].model)
const settings = {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "fixture" },
body: { future_option: true },
}
const routes = ["moonshot-chat", "moonshot-chat", "moonshot-messages", "moonshot-responses"]
modules.forEach((module, index) => {
const selected = module.model("kimi-k3", settings)
expect(selected.provider).toBe("moonshotai")
expect(selected.route.id).toBe(routes[index])
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body)
})
})
test("maps MiniMax API entrypoints onto provider-owned routes", async () => {
const modules = await Promise.all([
import("@opencode/ai/providers/minimax"),
@@ -76,6 +111,41 @@ describe("provider package entrypoints", () => {
})
})
test("maps ZAI and Coding Plan entrypoints onto distinct provider-owned routes", async () => {
const modules = await Promise.all([
import("@opencode/ai/providers/zai"),
import("@opencode/ai/providers/zai/chat"),
import("@opencode/ai/providers/zai-coding-plan"),
import("@opencode/ai/providers/zai-coding-plan/chat"),
import("@opencode/ai/providers/zai-coding-plan/messages"),
import("@opencode/ai/providers/zai-coding-plan/responses"),
])
expect(modules[0].model).toBe(modules[1].model)
expect(modules[2].model).toBe(modules[3].model)
const routes = [
"zai-chat",
"zai-chat",
"zai-coding-chat",
"zai-coding-chat",
"zai-coding-messages",
"zai-coding-responses",
]
const settings = {
apiKey: "fixture",
baseURL: "https://gateway.example/custom",
headers: { "x-test": "fixture" },
body: { extension: true },
}
modules.forEach((module, index) => {
const selected = module.model("glm-5.3", settings)
expect(selected.provider).toBe(index < 2 ? "zai" : "zai-coding-plan")
expect(selected.route.id).toBe(routes[index])
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body)
})
})
test("maps DeepInfra package settings onto its native executable model", async () => {
const DeepInfra = await import("@opencode/ai/providers/deepinfra")
const settings = {
@@ -0,0 +1,131 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js"
import { Moonshot } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { recordedTests } from "../recorded-test.js"
const apiKey = process.env.MOONSHOT_API_KEY ?? process.env.MOONSHOTAI_API_KEY ?? "fixture"
const requires = [process.env.MOONSHOT_API_KEY ? "MOONSHOT_API_KEY" : "MOONSHOTAI_API_KEY"]
const schema = {
type: "object",
properties: { city: { type: "string", enum: ["Paris"] } },
required: ["city"],
additionalProperties: false,
}
const weather = ToolDefinition.make({
name: "get_weather",
description: "Get the current weather in a city",
inputSchema: schema,
})
for (const api of ["chat", "messages", "responses"] as const) {
const model = Moonshot.configure({
apiKey,
providerOptions: api === "messages" ? { effort: "low" } : { reasoningEffort: "low" },
})[api]("kimi-k3")
const recorded = recordedTests({
prefix: `moonshot-${api}`,
provider: "moonshot",
protocol: api,
requires,
metadata: { model: "kimi-k3" },
})
for (const toolChoice of ["required", "none"] as const) {
recorded.effect.with(
`K3 ${api === "responses" ? "rejects unsupported" : "respects"} tool choice ${toolChoice}`,
{ tags: ["tool", "tool-choice", api === "responses" ? "error" : "usage"] },
() =>
Effect.gen(function* () {
const generate = LLMClient.generate(
LLM.request({
model,
prompt: "Use get_weather to look up the current weather in Paris.",
tools: [weather],
toolChoice,
generation: { maxTokens: 4096 },
}),
)
if (api === "responses") {
const error = yield* generate.pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.reason.http?.status).toBe(400)
expect(error.message).toContain(`unsupported tool_choice value: "${toolChoice}"`)
return
}
const response = yield* generate
if (toolChoice === "required") {
expect(response.toolCalls).toMatchObject([{ name: "get_weather", input: { city: "Paris" } }])
expect(response.finishReason.normalized).toBe("tool-calls")
}
if (toolChoice === "none") {
expect(response.toolCalls).toHaveLength(0)
expect(response.text.length).toBeGreaterThan(0)
expect(response.finishReason.normalized).toBe("stop")
}
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
}),
120_000,
)
}
recorded.effect.with(
"K3 reads image bytes",
{ tags: ["image", "reasoning", "usage"] },
() =>
Effect.gen(function* () {
const image = yield* Effect.promise(() =>
Bun.file(new URL("../fixtures/media/restroom.png", import.meta.url)).bytes(),
)
const response = yield* LLMClient.generate(
LLM.request({
model,
messages: [
Message.user([
{ type: "text", text: "Read the three words in this image. Reply only with those words in order." },
{ type: "media", mediaType: "image/png", data: image },
]),
],
generation: { maxTokens: 4096 },
}),
)
expect(response.text.toLowerCase()).toContain("jiggling restroom prison")
expect(response.finishReason.normalized).toBe("stop")
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
}),
120_000,
)
recorded.effect.with(
"K3 returns native structured output",
{ tags: ["structured-output", "usage"] },
() =>
Effect.gen(function* () {
const format = { type: "json_schema", name: "location", schema, strict: true }
const response = yield* LLMClient.generate(
LLM.request({
model,
prompt: "Return a JSON object containing the capital city of France.",
generation: { maxTokens: 4096 },
http: {
body:
api === "chat"
? {
response_format: { type: "json_schema", json_schema: { name: "location", schema, strict: true } },
}
: api === "messages"
? { output_config: { format: { type: "json_schema", schema } } }
: { text: { format } },
},
}),
)
expect(JSON.parse(response.text)).toEqual({ city: "Paris" })
expect(response.finishReason.normalized).toBe("stop")
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
}),
120_000,
)
}
@@ -0,0 +1,191 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMRequest, Message, ToolDefinition, type LLMResponse } from "../../src/index.js"
import { Moonshot } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { recordedTests } from "../recorded-test.js"
const apiKey = process.env.MOONSHOT_API_KEY ?? process.env.MOONSHOTAI_API_KEY ?? "fixture"
const requires = [process.env.MOONSHOT_API_KEY ? "MOONSHOT_API_KEY" : "MOONSHOTAI_API_KEY"]
for (const api of ["chat", "messages", "responses"] as const) {
const recorded = recordedTests({ prefix: `moonshot-${api}`, provider: "moonshot", protocol: api, requires })
for (const effort of [undefined, "low", "high", "max"] as const) {
recorded.effect.with(
`K3 streams text with ${effort ?? "default"} effort`,
{ tags: ["text", "reasoning", "usage"], metadata: { model: "kimi-k3" } },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: Moonshot.configure({
apiKey,
providerOptions: api === "messages" ? { effort } : { reasoningEffort: effort },
})[api]("kimi-k3"),
prompt: "What is 173 multiplied by 219? Reply with only the final integer.",
generation: { maxTokens: 4096 },
})
const compiled = yield* compileRequest(request)
expect(compiled.body.thinking).toBeUndefined()
if (effort !== undefined) {
expect(compiled.body).toMatchObject(
api === "chat"
? { reasoning_effort: effort }
: api === "messages"
? { output_config: { effort } }
: { reasoning: { effort } },
)
}
const response = yield* LLMClient.generate(request)
expect(response.text.replaceAll(",", "")).toContain("37887")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.finishReason.normalized).toBe("stop")
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
expectUsage(response)
}),
120_000,
)
}
}
const chat = recordedTests({ prefix: "moonshot-chat", provider: "moonshot", protocol: "moonshot-chat", requires })
for (const item of [
{ model: "kimi-k2.6", name: "default thinking", thinking: undefined },
{ model: "kimi-k2.6", name: "disabled thinking", thinking: { type: "disabled" } },
{ model: "kimi-k2.6", name: "enabled thinking", thinking: { type: "enabled", keep: null } },
{ model: "kimi-k2.7-code", name: "default thinking", thinking: undefined },
{ model: "kimi-k2.7-code-highspeed", name: "default thinking", thinking: undefined },
]) {
chat.effect.with(
`${item.model} streams text with ${item.name}`,
{
tags: ["text", "usage", item.thinking?.type === "disabled" ? "thinking-off" : "reasoning"],
metadata: { model: item.model },
},
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: Moonshot.configure({ apiKey, providerOptions: { thinking: item.thinking } }).chat(item.model),
prompt: "What is 173 multiplied by 219? Reply with only the final integer.",
generation: { maxTokens: 4096 },
}),
)
expect(response.text.replaceAll(",", "")).toContain("37887")
expect(response.reasoning.length > 0).toBe(item.thinking?.type !== "disabled")
expect(response.finishReason.normalized).toBe("stop")
expectUsage(response)
}),
120_000,
)
}
const weather = ToolDefinition.make({
name: "get_weather",
description: "Get the current weather in a city",
inputSchema: {
type: "object",
properties: { city: { type: "string", enum: ["Paris"] } },
required: ["city"],
additionalProperties: false,
},
})
for (const item of [
{ api: "chat", model: "kimi-k3", options: { reasoningEffort: "low" } },
{ api: "messages", model: "kimi-k3", options: { effort: "low" } },
{ api: "responses", model: "kimi-k3", options: { reasoningEffort: "low" } },
{ api: "chat", model: "kimi-k2.6", options: { thinking: { type: "enabled", keep: "all" } } },
{ api: "chat", model: "kimi-k2.7-code", options: {} },
{ api: "chat", model: "kimi-k2.7-code-highspeed", options: {} },
] as const) {
const recorded = recordedTests({ prefix: `moonshot-${item.api}`, provider: "moonshot", protocol: item.api, requires })
recorded.effect.with(
`${item.model} preserves reasoning through a tool loop and follow-up`,
{ tags: ["tool", "tool-loop", "reasoning", "continuation", "usage"], metadata: { model: item.model } },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: Moonshot.configure({ apiKey, providerOptions: item.options })[item.api](item.model),
prompt:
"Use get_weather to look up the current weather in Paris. After receiving the result, report the weather in one short sentence.",
tools: [weather],
generation: { maxTokens: 4096 },
})
const first = yield* LLMClient.generate(request)
expect(first.finishReason.normalized).toBe("tool-calls")
expect(first.toolCalls).toMatchObject([{ name: "get_weather", input: { city: "Paris" } }])
expect(first.reasoning.length).toBeGreaterThan(0)
expectUsage(first)
const continuation = LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
...first.toolCalls.map((call) =>
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperature: "18C" } }),
),
],
})
const compiled = yield* compileRequest(continuation)
if (item.api === "chat")
expect(compiled.body.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({ role: "assistant", reasoning_content: first.reasoning }),
]),
)
if (item.api === "messages")
expect(compiled.body.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "assistant",
content: expect.arrayContaining(
first.message.content
.filter((part) => part.type === "reasoning")
.map((part) => ({
type: "thinking",
thinking: part.text,
signature: part.providerMetadata?.moonshot?.signature ?? "",
})),
),
}),
]),
)
if (item.api === "responses")
expect(compiled.body.input).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "reasoning",
summary: expect.arrayContaining([{ type: "summary_text", text: first.reasoning }]),
}),
]),
)
const second = yield* LLMClient.generate(continuation)
expect(second.text).toContain("Paris")
expect(second.text.toLowerCase()).toContain("sunny")
expect(second.toolCalls).toHaveLength(0)
expect(second.finishReason.normalized).toBe("stop")
expectUsage(second)
const third = yield* LLMClient.generate(
LLMRequest.update(continuation, {
messages: [
...continuation.messages,
second.message,
Message.user("What temperature did the tool report? Reply with only the temperature."),
],
}),
)
expect(third.text).toContain("18")
expect(third.toolCalls).toHaveLength(0)
expect(third.finishReason.normalized).toBe("stop")
expectUsage(third)
}),
180_000,
)
}
function expectUsage(response: LLMResponse) {
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
}
+214
View File
@@ -0,0 +1,214 @@
import { expect } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Auth, LLM, LLMClient, Message, ReasoningPart, ToolDefinition } from "../../src/index.js"
import { Moonshot } from "../../src/providers.js"
import { AnthropicMessages } from "../../src/protocols/anthropic-messages.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { compileRequest } from "../../src/route/client.js"
import { Endpoint } from "../../src/route/endpoint.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
it.effect("Moonshot composes shared protocols with explicit endpoints and bearer authentication", () =>
Effect.gen(function* () {
const moonshot = Moonshot.configure()
expect(moonshot.model).toBe(moonshot.chat)
expect(moonshot.messages("kimi-k3").route.body).toBe(AnthropicMessages.protocol.body)
expect(moonshot.responses("kimi-k3").route.body).toBe(OpenResponses.protocol.body)
for (const item of [
{ model: moonshot.chat("kimi-k3"), path: "/v1/chat/completions" },
{ model: moonshot.messages("kimi-k3"), path: "/anthropic/v1/messages" },
{ model: moonshot.responses("kimi-k3"), path: "/v1/responses" },
]) {
const request = LLM.request({ model: item.model, prompt: "Hello" })
const compiled = yield* compileRequest(request)
const url = Endpoint.render(item.model.route.endpoint, { request, body: compiled.body }).toString()
expect(url).toBe(`https://api.moonshot.ai${item.path}`)
expect(item.model.provider).toBe("moonshotai")
expect(item.model.route.providerMetadataKey).toBe("moonshot")
const headers = yield* item.model.route.auth.apply({
request,
method: "POST",
url,
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBe("Bearer primary")
}
}).pipe(
Effect.provide(
ConfigProvider.layer(
ConfigProvider.fromEnv({ env: { MOONSHOT_API_KEY: "primary", MOONSHOTAI_API_KEY: "fallback" } }),
),
),
),
)
it.effect("Moonshot preserves default reasoning across model IDs on custom endpoints", () =>
Effect.gen(function* () {
const moonshot = Moonshot.configure({
baseURL: "https://gateway.example/v1",
auth: Auth.header("x-api-key", "fixture"),
})
for (const id of ["kimi-k2.6", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k3", "future-model"]) {
const model = moonshot.chat(id)
const request = LLM.request({ model, prompt: "Hello", generation: { maxTokens: 100 } })
const compiled = yield* compileRequest(request)
expect(compiled.body).toMatchObject({ model: id, max_tokens: 100, stream: true })
expect(compiled.body.max_completion_tokens).toBeUndefined()
expect(compiled.body.store).toBeUndefined()
expect(compiled.body.thinking).toBeUndefined()
expect(compiled.body.reasoning_effort).toBeUndefined()
expect(model.route.endpoint.baseURL).toBe("https://gateway.example/v1")
const headers = yield* model.route.auth.apply({
request,
method: "POST",
url: "https://gateway.example/v1/chat/completions",
body: "{}",
headers: Headers.empty,
})
expect(headers["x-api-key"]).toBe("fixture")
expect(headers.authorization).toBeUndefined()
}
}),
)
it.effect("Moonshot honors fallback credentials and final HTTP body overlays", () =>
LLMClient.generate(
LLM.request({
model: Moonshot.configure({ providerOptions: { reasoningEffort: "high" } }).responses("kimi-k3"),
prompt: "Hello",
providerOptions: { reasoningEffort: "low" },
http: { body: { reasoning: { effort: "max" }, future_option: true } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.sync(() => {
expect(input.request.headers.authorization).toBe("Bearer fallback")
expect(JSON.parse(input.text)).toMatchObject({ reasoning: { effort: "max" }, future_option: true })
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { MOONSHOTAI_API_KEY: "fallback" } }))),
),
)
it.effect("Moonshot lowers API-specific reasoning options without adding unrelated controls", () =>
Effect.gen(function* () {
const moonshot = Moonshot.configure({ apiKey: "fixture" })
for (const thinking of [{ type: "disabled" }, { type: "enabled", keep: "all" }, { type: "enabled", keep: null }]) {
const result = yield* compileRequest(
LLM.request({ model: moonshot.chat("kimi-k2.6"), providerOptions: { thinking } }),
)
expect(result.body.thinking).toEqual(thinking)
expect(result.body.reasoning_effort).toBeUndefined()
}
for (const effort of ["low", "high", "max", "future-effort"]) {
const chat = yield* compileRequest(
LLM.request({ model: moonshot.chat("kimi-k3"), providerOptions: { reasoningEffort: effort } }),
)
const messages = yield* compileRequest(
LLM.request({ model: moonshot.messages("kimi-k3"), providerOptions: { effort } }),
)
const responses = yield* compileRequest(
LLM.request({ model: moonshot.responses("kimi-k3"), providerOptions: { reasoningEffort: effort } }),
)
expect(chat.body.reasoning_effort).toBe(effort)
expect(messages.body.output_config).toEqual({ effort })
expect(responses.body.reasoning).toEqual({ effort })
for (const compiled of [chat, messages, responses]) expect(compiled.body.thinking).toBeUndefined()
expect(responses.body.store).toBeUndefined()
expect(responses.body.include).toBeUndefined()
}
}),
)
it.effect("Moonshot validates native Chat thinking options", () =>
Effect.gen(function* () {
const invalid = Moonshot.configure({ apiKey: "fixture" })
.chat("kimi-k2.6")
.route.with({ providerOptions: { thinking: { type: "enabled", keep: 42 } } })
.model({ id: "kimi-k2.6" })
const failure = yield* compileRequest(LLM.request({ model: invalid })).pipe(Effect.flip)
expect(failure.reason._tag).toBe("InvalidRequest")
expect(failure.message).toContain("keep")
}),
)
it.effect("Moonshot retains unsigned and signed Messages reasoning and projects tool schemas", () =>
Effect.gen(function* () {
const compiled = yield* compileRequest(
LLM.request({
model: Moonshot.configure({ apiKey: "fixture" }).messages("kimi-k3"),
messages: [
Message.user("Hello"),
Message.assistant([
ReasoningPart.make({ type: "reasoning", text: "Unsigned" }),
ReasoningPart.make({
type: "reasoning",
text: "Signed",
providerMetadata: { moonshot: { signature: "opaque-signature" } },
}),
]),
],
tools: [
ToolDefinition.make({
name: "lookup",
description: "Look up a pair",
inputSchema: {
type: "object",
properties: { pair: { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] } },
},
}),
],
}),
)
expect(compiled.body.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "assistant",
content: [
{ type: "thinking", thinking: "Unsigned", signature: "" },
{ type: "thinking", thinking: "Signed", signature: "opaque-signature" },
],
}),
]),
)
expect(compiled.body.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({
input_schema: {
type: "object",
properties: { pair: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } } },
},
}),
]),
)
}),
)
it.effect("Moonshot Responses uses HTTP even with a WebSocket executor", () =>
LLMClient.generate(
LLM.request({ model: Moonshot.configure({ apiKey: "fixture" }).responses("kimi-k3"), prompt: "Hello" }),
{
webSocket: { execute: () => Effect.die("Unexpected WebSocket request") },
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.sync(() => {
expect(input.request.url).toBe("https://api.moonshot.ai/v1/responses")
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
@@ -0,0 +1,156 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js"
import { ZAI } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { recordedTests } from "../recorded-test.js"
const apiKey = process.env.ZAI_API_KEY ?? "fixture"
const recorded = recordedTests({ prefix: "zai-chat", provider: "zai", protocol: "zai-chat", requires: ["ZAI_API_KEY"] })
recorded.effect.with(
"GLM 5.3 rejects disabled thinking",
{ tags: ["thinking", "model-specific", "error"] },
() =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({
model: ZAI.configure({ apiKey, providerOptions: { thinking: { type: "disabled" } } }).chat("glm-5.3"),
prompt: "What is 173 multiplied by 219? Reply with only the final integer.",
generation: { maxTokens: 4096 },
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.reason.http?.status).toBe(400)
expect(error.message).toContain("always engages in thinking and cannot be disabled")
}),
120_000,
)
for (const modelID of ["glm-4.7", "glm-5.2"]) {
for (const type of ["enabled", "disabled"] as const) {
recorded.effect.with(
`${modelID} streams thinking ${type}`,
{ tags: ["text", "thinking", "usage"] },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: ZAI.configure({ apiKey, providerOptions: { thinking: { type, clear_thinking: true } } }).chat(
modelID,
),
prompt: "What is 173 multiplied by 219? Reply with only the final integer.",
generation: { maxTokens: 4096 },
}),
)
expect(response.text.replaceAll(",", "")).toContain("37887")
expect(response.reasoning.length > 0).toBe(type === "enabled")
expect(response.finishReason.normalized).toBe("stop")
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
}),
120_000,
)
}
}
for (const effort of ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const) {
recorded.effect.with(
`GLM 5.2 streams ${effort} effort`,
{ tags: ["text", "effort", "usage"] },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: ZAI.configure({ apiKey, providerOptions: { reasoningEffort: effort } }).chat("glm-5.2"),
prompt: "What is 173 multiplied by 219? Reply with only the final integer.",
generation: { maxTokens: 4096 },
}),
)
expect(response.text.replaceAll(",", "")).toContain("37887")
// The direct API still returned reasoning for none/minimal in these recordings.
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.finishReason.normalized).toBe("stop")
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
}),
120_000,
)
}
recorded.effect.with(
"GLM 5.3 Flash reads image bytes",
{ tags: ["image", "reasoning"] },
() =>
Effect.gen(function* () {
const bytes = yield* Effect.promise(() =>
Bun.file(new URL("../fixtures/media/restroom.png", import.meta.url)).bytes(),
)
const response = yield* LLMClient.generate(
LLM.request({
model: ZAI.configure({ apiKey, providerOptions: { reasoningEffort: "low" } }).chat("glm-5.3-flash"),
messages: [
Message.user([
{ type: "text", text: "Read the three words in this image. Reply with only the words in order." },
{ type: "media", mediaType: "image/png", data: bytes },
]),
],
generation: { maxTokens: 4096 },
}),
)
expect(response.text.toLowerCase()).toContain("jiggling restroom prison")
expect(response.finishReason.normalized).toBe("stop")
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
}),
120_000,
)
recorded.effect.with(
"GLM 5.3 returns a JSON object",
{ tags: ["structured-output"] },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: ZAI.configure({
apiKey,
providerOptions: { reasoningEffort: "low", responseFormat: { type: "json_object" } },
}).chat("glm-5.3"),
prompt: 'Return a JSON object with one key "city" set to the capital city of France.',
generation: { maxTokens: 4096 },
}),
)
expect(JSON.parse(response.text)).toEqual({ city: "Paris" })
expect(response.finishReason.normalized).toBe("stop")
}),
120_000,
)
recorded.effect.with(
"GLM 4.5 calls a tool without streaming arguments",
{ tags: ["tool", "legacy"] },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: ZAI.configure({ apiKey }).chat("glm-4.5"),
prompt: "Use get_weather to look up the current weather in Paris.",
tools: [
ToolDefinition.make({
name: "get_weather",
description: "Get weather in a city",
inputSchema: {
type: "object",
properties: { city: { type: "string", enum: ["Paris"] } },
required: ["city"],
},
}),
],
generation: { maxTokens: 4096 },
}),
)
expect(response.toolCalls).toMatchObject([{ name: "get_weather", input: { city: "Paris" } }])
expect(response.finishReason.normalized).toBe("tool-calls")
}),
120_000,
)
@@ -0,0 +1,113 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMRequest, Message, ToolDefinition, type LLMResponse } from "../../src/index.js"
import { ZAI } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { recordedTests } from "../recorded-test.js"
const apiKey = process.env.ZAI_API_KEY ?? "fixture"
const recorded = recordedTests({ prefix: "zai-chat", provider: "zai", protocol: "zai-chat", requires: ["ZAI_API_KEY"] })
const weather = ToolDefinition.make({
name: "get_weather",
description: "Get the current weather in a city",
inputSchema: {
type: "object",
properties: { city: { type: "string", enum: ["Paris"] } },
required: ["city"],
additionalProperties: false,
},
})
for (const effort of [undefined, "low", "high", "max"] as const) {
recorded.effect.with(
`GLM 5.3 streams ${effort ?? "default"} effort`,
{ tags: ["text", "reasoning", "usage"] },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: ZAI.configure({ apiKey, providerOptions: { reasoningEffort: effort } }).model("glm-5.3"),
prompt: "What is 173 multiplied by 219? Reply with only the final integer.",
generation: { maxTokens: 4096 },
})
const compiled = yield* compileRequest(request)
expect(compiled.body.thinking).toBeUndefined()
expect(compiled.body.reasoning_effort).toBe(effort)
const response = yield* LLMClient.generate(request)
expect(response.text.replaceAll(",", "")).toContain("37887")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.finishReason.normalized).toBe("stop")
expectUsage(response)
}),
120_000,
)
}
recorded.effect.with(
"GLM 5.3 preserves reasoning through a tool loop and follow-up",
{ tags: ["tool", "tool-loop", "reasoning", "continuation"] },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: ZAI.configure({
apiKey,
providerOptions: { reasoningEffort: "high", thinking: { type: "enabled", clear_thinking: false } },
}).chat("glm-5.3"),
prompt:
"We have a budget of 38000 dollars for 219 trips costing 173 dollars each. Calculate whether that is affordable. If it is, use get_weather to look up the current weather in Paris. After receiving the result, report the weather in one short sentence.",
tools: [weather],
generation: { maxTokens: 4096 },
})
const first = yield* LLMClient.generate(request)
expect(first.toolCalls).toMatchObject([{ name: "get_weather", input: { city: "Paris" } }])
expect(first.finishReason.normalized).toBe("tool-calls")
expect(first.reasoning.length).toBeGreaterThan(0)
expect(first.events.some(LLMEvent.is.toolInputDelta)).toBe(true)
expectUsage(first)
const continuation = LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
...first.toolCalls.map((call) =>
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperature: "18C" } }),
),
],
})
const compiled = yield* compileRequest(continuation)
expect(compiled.body.messages).toEqual(
expect.arrayContaining([expect.objectContaining({ role: "assistant", reasoning_content: first.reasoning })]),
)
const second = yield* LLMClient.generate(continuation)
expect(second.text.toLowerCase()).toContain("sunny")
expect(second.toolCalls).toHaveLength(0)
expect(second.finishReason.normalized).toBe("stop")
expectUsage(second)
const followUp = LLMRequest.update(continuation, {
messages: [
...continuation.messages,
second.message,
Message.user("What temperature did the tool report? Reply with only the temperature."),
],
})
const replay = yield* compileRequest(followUp)
expect(replay.body.messages).toEqual(
expect.arrayContaining(
[first, second].map((response) =>
expect.objectContaining({ role: "assistant", reasoning_content: response.reasoning }),
),
),
)
const third = yield* LLMClient.generate(followUp)
expect(third.text).toContain("18")
expect(third.toolCalls).toHaveLength(0)
expect(third.finishReason.normalized).toBe("stop")
expectUsage(third)
}),
180_000,
)
function expectUsage(response: LLMResponse) {
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
}
+301
View File
@@ -0,0 +1,301 @@
import { expect } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Auth, LLM, LLMClient, Message, ReasoningPart, ToolDefinition } from "../../src/index.js"
import { ZAI, ZAICodingPlan } from "../../src/providers.js"
import { compileRequest } from "../../src/route/client.js"
import { Endpoint } from "../../src/route/endpoint.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const tool = ToolDefinition.make({
name: "lookup",
description: "Look up a value",
inputSchema: { type: "object", properties: { key: { type: "string" } } },
})
it.effect("ZAI owns standard and Coding Plan endpoints and bearer credentials", () =>
Effect.gen(function* () {
expect(ZAI.provider.model).toBe(ZAI.provider.chat)
expect(ZAICodingPlan.provider.model).toBe(ZAICodingPlan.provider.chat)
for (const item of [
{ model: ZAI.chat("glm-5.3"), path: "/paas/v4/chat/completions", provider: "zai" },
{ model: ZAICodingPlan.chat("glm-5.3"), path: "/coding/paas/v4/chat/completions", provider: "zai-coding-plan" },
{ model: ZAICodingPlan.messages("glm-5.3"), path: "/anthropic/v1/messages", provider: "zai-coding-plan" },
{ model: ZAICodingPlan.responses("glm-5.3"), path: "/v1/responses", provider: "zai-coding-plan" },
]) {
const request = LLM.request({ model: item.model, prompt: "Hello" })
const compiled = yield* compileRequest(request)
const url = Endpoint.render(item.model.route.endpoint, { request, body: compiled.body }).toString()
expect(url).toBe(`https://api.z.ai/api${item.path}`)
expect(item.model.provider).toBe(item.provider)
expect(item.model.route.providerMetadataKey).toBe("zai")
expect(compiled.body.thinking).toBeUndefined()
expect(compiled.body.reasoning_effort).toBeUndefined()
expect(compiled.body.reasoning).toBeUndefined()
expect(compiled.body.output_config).toBeUndefined()
const headers = yield* item.model.route.auth.apply({
request,
method: "POST",
url,
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBe("Bearer fixture")
}
}).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { ZAI_API_KEY: "fixture" } })))),
)
it.effect("ZAI Chat retains native thinking fields and scopes tool streaming to supported models", () =>
Effect.gen(function* () {
for (const configure of [ZAI.configure, ZAICodingPlan.configure]) {
const provider = configure({
baseURL: "https://gateway.example/custom",
auth: Auth.header("x-test-key", "fixture"),
})
for (const modelID of [
"glm-4.5",
"glm-4.5-air",
"glm-4.5-x",
"glm-4.6",
"glm-4.7-flash",
"glm-5.3",
"glm-5.3-flash",
"future-model",
]) {
const result = yield* compileRequest(
LLM.request({
model: provider.chat(modelID),
prompt: "Hello",
generation: { maxTokens: 123 },
tools: [tool],
providerOptions: { thinking: { type: "enabled", clear_thinking: false } },
}),
)
expect(result.body).toMatchObject({
model: modelID,
max_tokens: 123,
thinking: { type: "enabled", clear_thinking: false },
})
expect(result.body.tool_stream).toBe(
["glm-4.6", "glm-4.7-flash", "glm-5.3", "glm-5.3-flash"].includes(modelID) ? true : undefined,
)
expect(result.body.max_completion_tokens).toBeUndefined()
expect(result.body.store).toBeUndefined()
expect(result.body.tools).toEqual([
expect.objectContaining({ function: expect.not.objectContaining({ strict: expect.anything() }) }),
])
}
for (const thinking of [
undefined,
{ type: "disabled" },
{ clear_thinking: false },
{ type: "enabled", clear_thinking: true },
]) {
const result = yield* compileRequest(
LLM.request({
model: provider.chat("glm-5.2"),
providerOptions: {
thinking,
toolStream: false,
doSample: false,
responseFormat: { type: "json_object" },
requestID: "request-1",
userID: "user-1",
},
tools: [tool],
}),
)
expect(result.body.thinking).toEqual(thinking)
expect(result.body).toMatchObject({
tool_stream: false,
do_sample: false,
response_format: { type: "json_object" },
request_id: "request-1",
user_id: "user-1",
})
}
}
}),
)
it.effect("ZAI lowers effort using the selected native API without inventing thinking settings", () =>
Effect.gen(function* () {
for (const effort of ["none", "minimal", "low", "medium", "high", "xhigh", "max", "future-effort"]) {
const standard = yield* compileRequest(
LLM.request({
model: ZAI.configure({ apiKey: "fixture" }).chat("glm-5.3"),
providerOptions: { reasoningEffort: effort },
}),
)
const coding = ZAICodingPlan.configure({ apiKey: "fixture" })
const chat = yield* compileRequest(
LLM.request({ model: coding.chat("glm-5.3"), providerOptions: { reasoningEffort: effort } }),
)
const messages = yield* compileRequest(
LLM.request({ model: coding.messages("glm-5.3"), providerOptions: { effort } }),
)
const responses = yield* compileRequest(
LLM.request({ model: coding.responses("glm-5.3"), providerOptions: { reasoningEffort: effort } }),
)
expect(standard.body.reasoning_effort).toBe(effort)
expect(chat.body.reasoning_effort).toBe(effort)
expect(messages.body.output_config).toEqual({ effort })
expect(responses.body.reasoning).toEqual({ effort })
for (const result of [standard, chat, messages, responses]) expect(result.body.thinking).toBeUndefined()
}
for (const type of ["enabled", "adaptive", "disabled"]) {
const result = yield* compileRequest(
LLM.request({
model: ZAICodingPlan.configure({ apiKey: "fixture", providerOptions: { thinking: { type } } }).messages(
"glm-5.3",
),
}),
)
expect(result.body.thinking).toEqual({ type })
}
}),
)
it.effect("ZAI rejects invalid typed thinking options before execution", () =>
Effect.gen(function* () {
const model = ZAI.configure({ apiKey: "fixture" })
.chat("glm-5.3")
.route.with({ providerOptions: { thinking: { clear_thinking: "false" } } })
.model({ id: "glm-5.3" })
const error = yield* compileRequest(LLM.request({ model })).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("clear_thinking")
}),
)
it.effect("ZAI preserves unsigned and signed Messages reasoning", () =>
Effect.gen(function* () {
const result = yield* compileRequest(
LLM.request({
model: ZAICodingPlan.configure({ apiKey: "fixture" }).messages("glm-5.3"),
messages: [
Message.user("Hello"),
Message.assistant([
ReasoningPart.make({ type: "reasoning", text: "unsigned" }),
ReasoningPart.make({
type: "reasoning",
text: "signed",
providerMetadata: { zai: { signature: "opaque" } },
}),
]),
],
}),
)
expect(result.body.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "assistant",
content: [
{ type: "thinking", thinking: "unsigned", signature: "" },
{ type: "thinking", thinking: "signed", signature: "opaque" },
],
}),
]),
)
}),
)
it.effect("ZAI applies configured options, request options, and final body overlays in order", () =>
LLMClient.generate(
LLM.request({
model: ZAI.configure({
apiKey: "fixture",
baseURL: "https://gateway.example/custom",
providerOptions: { thinking: { type: "enabled", clear_thinking: true }, reasoningEffort: "high" },
}).chat("glm-5.3"),
prompt: "Hello",
providerOptions: { thinking: { clear_thinking: false }, reasoningEffort: "low" },
http: { body: { reasoning_effort: "max", extension: true } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.sync(() => {
expect(input.request.url).toBe("https://gateway.example/custom/chat/completions")
expect(input.request.headers.authorization).toBe("Bearer fixture")
expect(JSON.parse(input.text)).toMatchObject({
thinking: { type: "enabled", clear_thinking: false },
reasoning_effort: "max",
extension: true,
})
return input.respond(
sseEvents({ choices: [{ delta: { content: "Hello" }, finish_reason: "stop" }] }, "[DONE]"),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
),
)
it.effect("ZAI Coding Responses uses HTTP with a supplied WebSocket executor", () =>
LLMClient.generate(
LLM.request({ model: ZAICodingPlan.configure({ apiKey: "fixture" }).responses("glm-5.3"), prompt: "Hello" }),
{
webSocket: { execute: () => Effect.die("Unexpected WebSocket") },
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.sync(() => {
expect(input.request.url).toBe("https://api.z.ai/api/v1/responses")
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("ZAI Coding Messages serializes enabled thinking without a token budget", () =>
LLMClient.generate(
LLM.request({
model: ZAICodingPlan.configure({
apiKey: "fixture",
baseURL: "https://gateway.example/messages/v1",
providerOptions: { thinking: { type: "enabled" }, effort: "max" },
}).messages("glm-5.3"),
prompt: "Hello",
providerOptions: { effort: "high" },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.sync(() => {
expect(input.request.url).toBe("https://gateway.example/messages/v1/messages")
expect(input.request.headers.authorization).toBe("Bearer fixture")
expect(input.request.headers["anthropic-version"]).toBe("2023-06-01")
expect(JSON.parse(input.text)).toMatchObject({
thinking: { type: "enabled" },
output_config: { effort: "high" },
})
return input.respond(
sseEvents(
{
type: "message_start",
message: {
id: "msg_fixture",
role: "assistant",
model: "glm-5.3",
content: [],
usage: { input_tokens: 1, output_tokens: 0 },
},
},
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
),
)
+3 -2
View File
@@ -17,7 +17,8 @@ if (!(beta ? /^\d+\.\d+\.\d+-beta[.-]\d+(?:\.\d+)?$/ : /^\d+\.\d+\.\d+$/).test(S
}
const dir = fileURLToPath(new URL("..", import.meta.url))
const outdir = path.join(dir, "dist", `aur-${name}`)
const root = path.resolve(process.env.OPENCODE_CLI_DIST ?? path.join(dir, "dist"))
const outdir = path.join(root, `aur-${name}`)
const dryRun = process.argv.includes("--dry-run")
const pkgver = Script.version.replaceAll("-", ".")
const license = Bun.file(path.join(dir, "..", "..", "LICENSE"))
@@ -35,7 +36,7 @@ const sources = await Promise.all(
{ arch: "x86_64", target: "linux-x64-baseline" },
{ arch: "aarch64", target: "linux-arm64" },
].map(async (item) => {
const directory = path.join(dir, "dist", `cli-${item.target}`)
const directory = path.join(root, `cli-${item.target}`)
const pkg: { name: string; version: string } = await Bun.file(path.join(directory, "package.json")).json()
if (pkg.version !== Script.version) throw new Error(`Unexpected version for ${pkg.name}: ${pkg.version}`)
const archive = Bun.file(path.join(directory, `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`))
+54 -4
View File
@@ -4,9 +4,12 @@ import pkg from "../package.json"
import { Script } from "@opencode/script"
import { fileURLToPath } from "url"
import { existsSync } from "fs"
import { chmod, rm, utimes } from "node:fs/promises"
import path from "node:path"
import { UpdateArtifact } from "../../../script/update-artifact"
const dir = fileURLToPath(new URL("..", import.meta.url))
const root = path.resolve(process.env.OPENCODE_CLI_DIST ?? path.join(dir, "dist"))
process.chdir(dir)
const dryRun = process.argv.includes("--dry-run")
@@ -41,6 +44,7 @@ async function publishDistribution(input: {
if (versions.size > 1) throw new Error(`Binary package versions do not match for ${input.name}`)
const version = versions.values().next().value
if (!version) throw new Error(`No binary packages found for ${input.name}`)
if (version !== Script.version) throw new Error(`Expected ${Script.version}, got ${version} for ${input.name}`)
await $`mkdir -p ${input.root}/${input.name}/bin`
await $`cp ./script/postinstall.mjs ${input.root}/${input.name}/postinstall.mjs`
@@ -78,7 +82,31 @@ async function publishDistribution(input: {
),
)
await publish(`${input.root}/${input.name}`, input.name, version)
if (dryRun) return
const files = await UpdateArtifact.upload({
version,
files: await Promise.all(
Object.keys(binaries).map((name) =>
archive(
path.join(input.root, name.replace("@opencode/", ""), "bin"),
name.slice(input.packagePrefix.length),
input.binary,
input.root,
),
),
),
dryRun,
})
const artifact = {
channel: Script.channel,
name: input.artifact,
distribution: "opencode",
version,
metadata: { files },
}
if (dryRun) {
console.log(`dry-run artifact: ${JSON.stringify(artifact)}`)
return
}
await UpdateArtifact.publish({
channel: Script.channel,
name: input.artifact,
@@ -86,21 +114,43 @@ async function publishDistribution(input: {
version,
metadata: { package: input.name },
})
await UpdateArtifact.publish(artifact)
}
await publishDistribution({
root: "./dist",
root,
name: pkg.name,
binary: "opencode2",
packagePrefix: "@opencode/cli-",
artifact: "cli",
})
if (existsSync("./dist/node")) {
if (existsSync(path.join(root, "node"))) {
await publishDistribution({
root: "./dist/node",
root: path.join(root, "node"),
name: "@opencode/cli-node",
binary: "opencode2-node",
packagePrefix: "@opencode/cli-node-",
artifact: "cli-node",
})
}
if ((Script.channel === "beta" || Script.channel === "latest") && Script.release) {
await $`bun ./script/publish-aur.ts ${dryRun ? ["--dry-run"] : []}`.env({ ...process.env, OPENCODE_CLI_DIST: root })
}
async function archive(bin: string, target: string, binary: string, directory: string) {
const executable = `${binary}${target.startsWith("windows-") ? ".exe" : ""}`
const source = path.join(bin, executable)
if (!(await Bun.file(source).exists()) || !Bun.file(source).size) throw new Error(`Missing binary: ${source}`)
// GitHub artifact downloads lose execute bits. Fixed timestamps make retries reproducible.
await chmod(source, 0o755)
await utimes(source, new Date("1980-01-01T00:00:00Z"), new Date("1980-01-01T00:00:00Z"))
const extension = target.startsWith("linux-") ? "tar.gz" : "zip"
const output = path.join(directory, `${binary}-${target}.${extension}`)
await rm(output, { force: true })
if (extension === "tar.gz") {
await $`tar --mtime=@0 --owner=0 --group=0 --numeric-owner -czf ${output} -C ${bin} ${executable}`
}
if (extension === "zip") await $`zip -X -q ${output} ${executable}`.cwd(bin)
return output
}
+1 -1
View File
@@ -18,7 +18,7 @@ const content = await format(
if (process.argv.includes("--check")) {
if ((await Bun.file(target).text()) !== content) {
console.error("Generated CLI config schema is stale. Run `bun run generate` from packages/www.")
console.error("Generated CLI config schema is stale. Run `bun run generate` from services/www.")
process.exit(1)
}
process.exit(0)
@@ -1,90 +1,264 @@
import { Effect } from "effect"
import { define } from "@opencode/plugin/effect/plugin"
import { Form } from "@opencode/schema/form"
import { Clock, Deferred, Effect, Option, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { App } from "../../app.js"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { OauthCallbackPage } from "../../oauth/page.js"
import { Provider } from "../../provider.js"
import { configuredSettings } from "./configured.js"
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
// Exported for testing: intercepts Cortex-specific request/response quirks.
export function cortexFetch(upstream: FetchLike = fetch) {
return async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
if (init?.body && typeof init.body === "string") {
try {
const body = JSON.parse(init.body)
if ("max_tokens" in body) {
body.max_completion_tokens = body.max_tokens
delete body.max_tokens
init = { ...init, body: JSON.stringify(body) }
}
} catch {}
}
const response = await upstream(url, init)
// Cortex returns 400 "conversation complete" as a normal stop condition
if (response.status === 400) {
try {
const errorData = (await response.clone().json()) as Record<string, unknown>
if (
String(errorData.message || errorData.error || "")
.toLowerCase()
.includes("conversation complete")
) {
return new Response(
JSON.stringify({ choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }] }),
{ status: 200, headers: new Headers({ "content-type": "application/json" }) },
)
}
} catch {}
}
// Cortex returns role:"" in streaming deltas; the AI SDK schema requires "assistant"
if (response.body && response.headers.get("content-type")?.includes("text/event-stream")) {
const reader = response.body.getReader()
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const stream = new ReadableStream({
async pull(ctrl) {
const { done, value } = await reader.read()
if (done) {
ctrl.close()
return
}
ctrl.enqueue(
encoder.encode(decoder.decode(value, { stream: true }).replace(/"role"\s*:\s*""/g, '"role":"assistant"')),
)
},
cancel() {
reader.cancel()
},
})
return new Response(stream, { headers: response.headers, status: response.status })
}
return response
}
}
const providerID = Provider.ID.make("snowflake-cortex")
const integrationID = Integration.ID.make(providerID)
const methodID = Integration.MethodID.make("browser")
const clientID = "LOCAL_APPLICATION"
const accountForm = Form.Fields.make([
{
type: "string",
key: "account",
title: "Snowflake account",
placeholder: "myorg-myaccount",
required: true,
},
])
const Token = Schema.Struct({
access_token: Schema.NonEmptyString,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
})
const decodeError = Schema.decodeUnknownOption(
Schema.fromJsonString(
Schema.Struct({ message: Schema.optional(Schema.Unknown), error: Schema.optional(Schema.Unknown) }),
),
)
export const SnowflakeCortexPlugin = define({
id: "opencode.provider.snowflake.cortex",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.model.providerID !== Provider.ID.make("snowflake-cortex")) return
const token =
process.env.SNOWFLAKE_CORTEX_TOKEN ??
process.env.SNOWFLAKE_CORTEX_PAT ??
(typeof evt.options.token === "string" ? evt.options.token : undefined) ??
(typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined)
const upstream = typeof evt.options.fetch === "function" ? (evt.options.fetch as FetchLike) : undefined
if (evt.options.includeUsage !== false) evt.options.includeUsage = true
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
evt.sdk = mod.createOpenAICompatible({
...evt.options,
...(token ? { apiKey: token } : {}),
fetch: cortexFetch(upstream) as typeof fetch,
} as any)
const http = yield* HttpClient.HttpClient
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const bus = yield* Bus.Service
const configured = yield* configuredSettings(providerID)
const account = { value: "" }
const saved = Effect.gen(function* () {
const connection = yield* integrations.connection.active(integrationID)
return connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined
})
const token = Effect.fn(function* (account: string, form: Record<string, string>) {
if (!account) return yield* Effect.fail(new Error("Snowflake account is required"))
const response = yield* http.execute(
HttpClientRequest.post(`${issuer(account)}/oauth/token-request`).pipe(
HttpClientRequest.setHeaders({
Accept: "application/json",
"User-Agent": App.useragent(ctx.app),
// Same built-in OAuth client and Basic header as V1; this is not a user password.
Authorization: `Basic ${Buffer.from(`${clientID}:${clientID}`).toString("base64")}`,
}),
HttpClientRequest.bodyUrlParams({ ...form, client_id: clientID }),
),
)
if (response.status < 200 || response.status >= 300)
return yield* Effect.fail(
new Error(`Snowflake token request failed (${response.status}): ${yield* response.text}`),
)
const tokens = yield* HttpClientResponse.schemaBodyJson(Token)(response)
const refresh = tokens.refresh_token || form.refresh_token
if (!refresh) return yield* Effect.fail(new Error("Snowflake token response did not include refresh_token"))
return Credential.OAuth.make({
type: "oauth",
methodID,
access: tokens.access_token,
refresh,
expires: (yield* Clock.currentTimeMillis) + (tokens.expires_in ?? 600) * 1000,
metadata: { account },
})
})
const refresh = (value: Credential.OAuth) =>
token(normalizeAccount(value.metadata?.account), { grant_type: "refresh_token", refresh_token: value.refresh })
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID,
method: { type: "key", label: "Paste PAT or bearer token", form: accountForm },
})
// SNOWFLAKE_ACCOUNT configures the endpoint; it is not a token.
editor.method.update({
integrationID,
method: { type: "env", names: ["SNOWFLAKE_CORTEX_TOKEN", "SNOWFLAKE_CORTEX_PAT"] },
})
editor.method.update({
integrationID,
method: {
id: methodID,
type: "oauth",
label: "Login with Snowflake (External Browser)",
form: [...accountForm, { type: "string", key: "role", title: "Snowflake role (optional)" }],
},
// An environment-token override must work even when the saved OAuth login has expired.
refresh: (value) => (envToken() ? Effect.succeed(value) : refresh(value)),
label: (value) => normalizeAccount(value.metadata?.account),
authorize: (answer) =>
Effect.gen(function* () {
const account = normalizeAccount(answer.account)
if (!account) return yield* Effect.fail(new Error("Snowflake account is required"))
const role = typeof answer.role === "string" ? answer.role.trim() : ""
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(48))).toString("base64url")
const challenge = Buffer.from(
yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))),
).toString("base64url")
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
const code = yield* Deferred.make<string, Error>()
const { createServer } = yield* Effect.promise(() => import("node:http"))
const { EventEmitter } = yield* Effect.promise(() => import("node:events"))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1")
if (url.pathname !== "/") {
response.writeHead(404).end()
return
}
const value = url.searchParams.get("code")
const error =
url.searchParams.get("state") !== state
? "Invalid OAuth state"
: url.searchParams.get("error_description") ||
url.searchParams.get("error") ||
(!value ? "Missing authorization code" : undefined)
Effect.runFork(error ? Deferred.fail(code, new Error(error)) : Deferred.succeed(code, value ?? ""))
response
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
.end(
error
? OauthCallbackPage.error(error, { provider: "Snowflake" })
: OauthCallbackPage.success({ provider: "Snowflake" }),
)
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
yield* Effect.tryPromise(() => EventEmitter.once(server.listen(0, "127.0.0.1"), "listening"))
const address = server.address()
if (!address || typeof address === "string")
return yield* Effect.fail(new Error("Missing OAuth callback port"))
const redirect = `http://127.0.0.1:${address.port}/`
return {
mode: "auto" as const,
url: `${issuer(account)}/oauth/authorize?${new URLSearchParams({
client_id: clientID,
response_type: "code",
redirect_uri: redirect,
state,
scope: !role
? "refresh_token"
: /^[-_A-Za-z0-9]+$/.test(role)
? `refresh_token session:role:${role}`
: `refresh_token session:role-encoded:${encodeURIComponent(role)}`,
code_challenge: challenge,
code_challenge_method: "S256",
}).toString()}`,
instructions: "Complete Snowflake sign-in in your browser.",
callback: Effect.gen(function* () {
return yield* token(account, {
grant_type: "authorization_code",
code: yield* Deferred.await(code),
redirect_uri: redirect,
code_verifier: verifier,
})
}),
}
}),
})
})
const load = Effect.gen(function* () {
const value = (yield* saved)?.value
account.value = normalizeAccount(
process.env.SNOWFLAKE_ACCOUNT ??
(value?.type === "key"
? (value.configuration?.account ?? value.metadata?.account)
: value?.metadata?.account) ??
configured?.account,
)
})
yield* load
yield* ctx.catalog.transform((catalog) => {
const item = catalog.provider.get(providerID)
if (!item) return
const settings = { ...item.provider.settings, ...configured }
item.provider.settings = {
...settings,
baseURL: endpoint(settings.baseURL, account.value),
...(typeof settings.token === "string" ? { apiKey: settings.token } : {}),
}
for (const model of item.models.values()) {
model.compatibility = { maxTokensField: "max_completion_tokens", ...model.compatibility }
if (model.settings?.baseURL !== undefined)
model.settings.baseURL = endpoint(model.settings.baseURL, account.value)
}
})
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === integrationID),
Stream.runForEach(() => load.pipe(Effect.andThen(ctx.catalog.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* ctx.session.hook(
"http.request",
(event) =>
Effect.sync(() => {
// Model resolution already supplies and refreshes stored credentials on each attempt.
const token = envToken()
if (token) event.request.headers.set("authorization", `Bearer ${token}`)
event.request.headers.set("user-agent", App.useragent(ctx.app))
}),
{ providerID },
)
yield* ctx.session.hook(
"http.response",
Effect.fn(function* (event) {
if (event.response.status !== 400) return
const error = Option.getOrUndefined(decodeError(yield* Effect.promise(() => event.response.clone().text())))
// oxlint-disable-next-line typescript-eslint/no-base-to-string -- Preserve V1's error-body coercion.
const message = String(error?.message || error?.error || "")
if (!message.toLowerCase().includes("conversation complete")) return
event.response = new Response(
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
{ headers: { "content-type": "text/event-stream" } },
)
}),
{ providerID },
)
yield* ctx.session.hook(
"retry",
Effect.fn(function* (event) {
if (event.error.status !== 401 || event.attempt !== 2 || envToken()) return
const current = yield* saved
if (current?.value.type !== "oauth" || current.value.methodID !== methodID) return
yield* credentials.update(current.id, { value: yield* refresh(current.value).pipe(Effect.orDie) })
event.decision = { retry: true, delay: 0 }
}),
{ providerID },
)
}),
})
function normalizeAccount(value: unknown) {
if (typeof value !== "string") return ""
return value
.trim()
.replace(/^https?:\/\//i, "")
.replace(/\/+$/, "")
.replace(/\.snowflakecomputing\.com$/i, "")
}
function issuer(account: string) {
return `https://${account}.snowflakecomputing.com`
}
function endpoint(value: unknown, account: string) {
const baseURL = typeof value === "string" ? value : `${issuer("${SNOWFLAKE_ACCOUNT}")}/api/v2/cortex/v1`
return account ? baseURL.replaceAll("${SNOWFLAKE_ACCOUNT}", account) : baseURL
}
function envToken() {
return process.env.SNOWFLAKE_CORTEX_TOKEN ?? process.env.SNOWFLAKE_CORTEX_PAT
}
+1 -1
View File
@@ -135,7 +135,7 @@ secret into configuration.
For any request to migrate OpenCode configuration, agents, commands, skills,
plugins, integrations, or other behavior from V1 to V2, read the full
[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In
the repository, its source is `packages/www/src/docs/content/migrate-v1.mdx`.
the repository, its source is `services/www/src/docs/content/migrate-v1.mdx`.
V1 config files and `.opencode/` definitions are intended to remain compatible.
The only intentional breaking changes are the server API and plugin API. Native
+1 -1
View File
@@ -54,7 +54,7 @@ export const Input = Schema.Struct({
}),
background: Schema.optionalKey(Schema.Boolean).annotate({
description:
"Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
"Run the command in the background and return immediately (useful for dev servers and long-running builds). You do not need to use '&' at the end of the command when using this parameter. You will be notified when it completes. DO NOT poll for completion.",
}),
})
@@ -1,262 +1,290 @@
import { AISDK } from "@opencode/core/aisdk"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Message } from "@opencode/ai"
import { LLMClient, RequestExecutor } from "@opencode/ai/route"
import { Agent } from "@opencode/core/agent"
import { Catalog } from "@opencode/core/catalog"
import { Credential } from "@opencode/core/credential"
import { Integration } from "@opencode/core/integration"
import { Location } from "@opencode/core/location"
import { Model } from "@opencode/core/model"
import { ModelResolver } from "@opencode/core/model-resolver"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { SnowflakeCortexPlugin, cortexFetch } from "@opencode/core/plugin/provider/snowflake-cortex"
import { ProviderPlugins } from "@opencode/core/plugin/provider"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { SnowflakeCortexPlugin } from "@opencode/core/plugin/provider/snowflake-cortex"
import { Provider } from "@opencode/core/provider"
import { Session } from "@opencode/core/session"
import { SessionModelRequest } from "@opencode/core/session/model-request"
import { SessionModelTransport } from "@opencode/core/session/model-transport"
import { expect } from "bun:test"
import { Effect, Layer, Schedule, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Socket } from "effect/unstable/socket"
import { withEnv } from "../fixture/env"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const it = testEffect(
Layer.merge(PluginTestLayer, SessionModelTransport.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))),
)
const providerID = Provider.ID.make("snowflake-cortex")
const integrationID = Integration.ID.make(providerID)
const methodID = Integration.MethodID.make("browser")
const modelID = Model.ID.make("claude-sonnet-4-6")
const env = { SNOWFLAKE_ACCOUNT: undefined, SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }
const endpoint = "https://myorg-myaccount.snowflakecomputing.com"
const polling = { times: 100, schedule: Schedule.spaced("1 millis") }
const expiredOAuth = (account: string, refresh = "refresh") =>
Credential.OAuth.make({ type: "oauth", methodID, access: "expired", refresh, expires: 1, metadata: { account } })
const addPlugin = Effect.fn(function* () {
const fixture = Effect.fn(function* () {
const requests: Request[] = []
const replies: Response[] = []
const http = HttpClient.make((request) =>
Effect.gen(function* () {
requests.push(yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie))
const response = replies.shift()
if (!response) throw new Error(`Unexpected request: ${request.url}`)
return HttpClientResponse.fromWeb(request, response)
}),
)
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const sessions = yield* Session.Service
const location = yield* Location.Service
const hooks = yield* PluginHooks.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* SnowflakeCortexPlugin.effect(host)
})
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
)
}
describe("SnowflakeCortexPlugin", () => {
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
Effect.sync(() => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake.cortex")
const ids = ProviderPlugins.map((p) => p.id)
expect(ids.indexOf("opencode.provider.snowflake.cortex")).toBeLessThan(
ids.indexOf("opencode.provider.openai.compatible"),
)
}),
)
it.effect("ignores non-snowflake-cortex providers", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("openai"), Model.ID.make("gpt-4")),
modelID: Model.ID.make("gpt-4"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai",
options: { name: "openai" },
})
expect(result.sdk).toBeUndefined()
}),
)
it.effect("creates SDK for snowflake-cortex using SNOWFLAKE_CORTEX_PAT env var", () =>
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
modelID: Model.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
})
expect(result.sdk).toBeDefined()
}),
),
)
it.effect("falls back to options.apiKey when SNOWFLAKE_CORTEX_PAT env var is absent", () =>
withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
modelID: Model.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
options: {
name: "snowflake-cortex",
baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1",
apiKey: "options-pat",
},
})
expect(result.sdk).toBeDefined()
}),
),
)
it.effect("uses SNOWFLAKE_CORTEX_TOKEN env var", () =>
withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
modelID: Model.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
})
expect(result.sdk).toBeDefined()
}),
),
)
it.effect("falls back to options.token when no Snowflake env token is set", () =>
withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
modelID: Model.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
options: {
name: "snowflake-cortex",
baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1",
token: "options-token",
},
})
expect(result.sdk).toBeDefined()
}),
),
)
it.effect("sets includeUsage on the SDK options", () =>
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
modelID: Model.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
})
expect(result.options.includeUsage).toBe(true)
}),
),
)
})
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
describe("cortexFetch", () => {
test("rewrites max_tokens to max_completion_tokens", async () => {
const captured: RequestInit[] = []
const upstream: FetchLike = async (_url, init) => {
captured.push(init ?? {})
return new Response("{}", { status: 200 })
}
await cortexFetch(upstream)("https://test", {
method: "POST",
body: JSON.stringify({ model: "claude-sonnet-4-6", max_tokens: 1024 }),
yield* catalog.transform((editor) => {
editor.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://${SNOWFLAKE_ACCOUNT}.snowflakecomputing.com/api/v2/cortex/v1" }
})
const body = JSON.parse(captured[0].body as string)
expect(body.max_completion_tokens).toBe(1024)
expect(body.max_tokens).toBeUndefined()
editor.model.update(providerID, modelID, () => {})
})
test("preserves body when max_tokens is absent", async () => {
const captured: RequestInit[] = []
const upstream: FetchLike = async (_url, init) => {
captured.push(init ?? {})
return new Response("{}", { status: 200 })
}
const original = JSON.stringify({ model: "claude-sonnet-4-6", temperature: 0.7 })
await cortexFetch(upstream)("https://test", { method: "POST", body: original })
expect(captured[0].body).toBe(original)
})
test("treats 400 'conversation complete' as a stop response", async () => {
const upstream: FetchLike = async () =>
new Response(JSON.stringify({ message: "Conversation complete" }), {
status: 400,
headers: { "content-type": "application/json" },
})
const response = await cortexFetch(upstream)("https://test", {})
expect(response.status).toBe(200)
const data = (await response.json()) as { choices: { finish_reason: string }[] }
expect(data.choices[0].finish_reason).toBe("stop")
})
test("passes through other 400 errors unchanged", async () => {
const upstream: FetchLike = async () =>
new Response(JSON.stringify({ message: "Invalid model" }), {
status: 400,
headers: { "content-type": "application/json" },
})
const response = await cortexFetch(upstream)("https://test", {})
expect(response.status).toBe(400)
})
test("passes through non-400 errors unchanged", async () => {
const upstream: FetchLike = async () => new Response("Unauthorized", { status: 401 })
const response = await cortexFetch(upstream)("https://test", {})
expect(response.status).toBe(401)
})
test("handles invalid JSON body gracefully without throwing", async () => {
const captured: RequestInit[] = []
const upstream: FetchLike = async (_url, init) => {
captured.push(init ?? {})
return new Response("{}", { status: 200 })
}
const invalidBody = "{ not json }"
await cortexFetch(upstream)("https://test", { method: "POST", body: invalidBody })
expect(captured[0].body).toBe(invalidBody)
})
test("rewrites role:'' to role:'assistant' in streaming SSE chunks", async () => {
const chunk = `data: {"choices":[{"delta":{"role":"","content":"Hi"},"index":0}]}\n\n`
const upstream: FetchLike = async () =>
new Response(
new ReadableStream({
start: (ctrl) => {
ctrl.enqueue(new TextEncoder().encode(chunk))
ctrl.close()
},
}),
{
status: 200,
headers: { "content-type": "text/event-stream" },
},
)
const response = await cortexFetch(upstream)("https://test", {})
const text = await response.text()
expect(text).toContain('"role":"assistant"')
expect(text).not.toContain('"role":""')
yield* SnowflakeCortexPlugin.effect(host).pipe(Effect.provideService(HttpClient.HttpClient, http))
const session = yield* sessions.create({ location: Location.Ref.make({ directory: location.directory }) })
const scope = {
sessionID: session.id,
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: modelID }),
}
const send = Effect.gen(function* () {
const model = yield* Effect.gen(function* () {
const model = yield* catalog.model.get(providerID, modelID)
if (!model || String(model.settings?.baseURL).includes("${")) return yield* Effect.fail("Catalog pending")
return model
}).pipe(Effect.retry(polling))
const resolver = yield* ModelResolver.Service
const resolved = yield* resolver.resolveModel(model)
const service = yield* SessionModelRequest.Service
const prepared = yield* service.prepare({
kind: "primary",
scope: { session, agentID: scope.agent, model: resolved },
transcript: { system: [], messages: [Message.user("Hello")] },
})
return yield* LLMClient.stream(prepared.request, prepared.options).pipe(
Stream.runCollect,
Effect.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer), Layer.fresh)),
Effect.provideService(HttpClient.HttpClient, http),
)
}).pipe(Effect.provide(SessionModelRequest.layer), Effect.provide(ModelResolver.layer))
const stop = Effect.gen(function* () {
replies.push(Response.json({ message: "Conversation complete", error: {} }, { status: 400 }))
expect((yield* send).find((event) => event.type === "finish")?.reason.normalized).toBe("stop")
})
const status = (attemptID: Integration.AttemptID) =>
integrations.oauth
.status({ integrationID, attemptID })
.pipe(Effect.repeat({ ...polling, until: (status) => status.status !== "pending" }))
return { requests, replies, catalog, integrations, hooks, scope, send, stop, status }
})
it.live("browser OAuth supplies the native endpoint/token and refreshes a rejected token once", () =>
withEnv(env, () =>
Effect.gen(function* () {
const test = yield* fixture()
const attempt = yield* test.integrations.oauth.connect({
integrationID,
methodID,
answer: { account: `${endpoint}///`, role: "My Role" },
})
const url = new URL(attempt.url)
expect(url.origin + url.pathname).toBe(`${endpoint}/oauth/authorize`)
expect(url.searchParams.get("scope")).toBe("refresh_token session:role-encoded:My%20Role")
const callback = new URL(url.searchParams.get("redirect_uri") ?? "")
callback.searchParams.set("state", url.searchParams.get("state") ?? "")
callback.searchParams.set("code", "auth-code")
test.replies.push(Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 3600 }))
yield* Effect.promise(() => fetch(callback))
expect((yield* test.status(attempt.attemptID)).status).toBe("complete")
const exchange = test.requests[0]
expect(exchange.url).toBe(`${endpoint}/oauth/token-request`)
expect(exchange.headers.get("authorization")).toBe(
`Basic ${Buffer.from("LOCAL_APPLICATION:LOCAL_APPLICATION").toString("base64")}`,
)
const form = new URLSearchParams(yield* Effect.promise(() => exchange.text()))
expect(Object.fromEntries(form)).toMatchObject({
grant_type: "authorization_code",
code: "auth-code",
redirect_uri: url.searchParams.get("redirect_uri"),
})
const challenge = Buffer.from(
yield* Effect.promise(() =>
crypto.subtle.digest("SHA-256", new TextEncoder().encode(form.get("code_verifier") ?? "")),
),
).toString("base64url")
expect(url.searchParams.get("code_challenge")).toBe(challenge)
test.replies.push(new Response("Unauthorized", { status: 401 }))
expect((yield* test.send.pipe(Effect.exit))._tag).toBe("Failure")
expect(test.requests[1].headers.get("authorization")).toBe("Bearer access")
test.replies.push(Response.json({ access_token: "renewed", refresh_token: "", expires_in: 3600 }))
const retry = {
...test.scope,
error: { type: "provider.authentication", message: "Unauthorized", status: 401 },
attempt: 2,
decision: { retry: false as const },
}
expect((yield* test.hooks.trigger("session", "retry", retry)).decision).toEqual({ retry: true, delay: 0 })
const refresh = new URLSearchParams(yield* Effect.promise(() => test.requests[2].text()))
expect(Object.fromEntries(refresh)).toMatchObject({ grant_type: "refresh_token", refresh_token: "refresh" })
yield* test.stop
expect(test.requests[3].headers.get("authorization")).toBe("Bearer renewed")
expect(
(yield* test.hooks.trigger("session", "retry", { ...retry, attempt: 3, decision: { retry: false } })).decision
.retry,
).toBe(false)
const credentials = yield* Credential.Service
expect((yield* credentials.list(integrationID))[0]?.value).toMatchObject({
access: "renewed",
refresh: "refresh",
})
}),
),
)
it.live("manual PAT uses its account and native compatibility rather than an SDK request rewrite", () =>
withEnv(env, () =>
Effect.gen(function* () {
const test = yield* fixture()
yield* test.integrations.connection.key({ integrationID, key: "pat", answer: { account: `${endpoint}/` } })
yield* test.hooks.register(
"session",
"context",
(event) =>
Effect.sync(() => {
event.generation.maxTokens = 1024
}),
{ providerID },
)
test.replies.push(
new Response(
'data: {"choices":[{"index":0,"delta":{"role":"","content":"Hello"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
{ headers: { "content-type": "text/event-stream" } },
),
)
expect(yield* test.send).toContainEqual(expect.objectContaining({ type: "text-delta", text: "Hello" }))
const request = test.requests[0]
expect(request.url).toBe(`${endpoint}/api/v2/cortex/v1/chat/completions`)
expect(request.headers.get("authorization")).toBe("Bearer pat")
const body = yield* Effect.promise(() => request.json())
expect(body).toMatchObject({ model: modelID, max_completion_tokens: 1024 })
expect(body).not.toHaveProperty("max_tokens")
}),
),
)
it.live("rejects a mismatched callback state before token exchange", () =>
withEnv(env, () =>
Effect.gen(function* () {
const test = yield* fixture()
const attempt = yield* test.integrations.oauth.connect({
integrationID,
methodID,
answer: { account: "myorg-myaccount" },
})
const callback = new URL(new URL(attempt.url).searchParams.get("redirect_uri") ?? "")
callback.searchParams.set("state", "wrong")
callback.searchParams.set("code", "forged")
expect((yield* Effect.promise(() => fetch(callback))).status).toBe(400)
expect(yield* test.status(attempt.attemptID)).toMatchObject({ status: "failed", message: "Invalid OAuth state" })
expect(test.requests).toHaveLength(0)
}),
),
)
it.live("uses environment tokens rather than the account identifier and bypasses expired stored OAuth", () =>
withEnv(
{ ...env, SNOWFLAKE_ACCOUNT: `${endpoint}/`, SNOWFLAKE_CORTEX_TOKEN: "env-token", SNOWFLAKE_CORTEX_PAT: "env-pat" },
() =>
Effect.gen(function* () {
const test = yield* fixture()
expect(yield* test.integrations.connection.active(integrationID)).toEqual({
type: "env",
name: "SNOWFLAKE_CORTEX_TOKEN",
})
expect((yield* test.catalog.provider.get(providerID))?.package).toBe(
Provider.aisdk("@ai-sdk/openai-compatible"),
)
expect(yield* test.hooks.has("aisdk", "sdk", providerID)).toBe(false)
yield* test.stop
expect(test.requests[0].url).toBe(`${endpoint}/api/v2/cortex/v1/chat/completions`)
expect(test.requests[0].headers.get("authorization")).toBe("Bearer env-token")
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID,
value: expiredOAuth("other-account", "revoked-refresh"),
})
yield* test.stop
expect(test.requests).toHaveLength(2)
expect(test.requests[1].url).toBe(`${endpoint}/api/v2/cortex/v1/chat/completions`)
expect(test.requests[1].headers.get("authorization")).toBe("Bearer env-token")
}),
),
)
it.live("requires a token separately from the account and supports environment PATs", () =>
withEnv({ ...env, SNOWFLAKE_ACCOUNT: "myorg-myaccount" }, () =>
Effect.gen(function* () {
const test = yield* fixture()
expect(yield* test.integrations.connection.active(integrationID)).toBeUndefined()
yield* withEnv({ SNOWFLAKE_CORTEX_PAT: "env-pat" }, () =>
Effect.gen(function* () {
yield* test.stop
expect(test.requests[0].headers.get("authorization")).toBe("Bearer env-pat")
test.replies.push(Response.json({ message: "Invalid model" }, { status: 400 }))
expect((yield* test.send.pipe(Effect.exit))._tag).toBe("Failure")
}),
)
}),
),
)
it.live("refreshes expired OAuth during native model resolution and persists rotated credentials", () =>
withEnv(env, () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const saved = yield* credentials.create({
integrationID,
value: expiredOAuth("myorg-myaccount"),
})
const test = yield* fixture()
test.replies.push(Response.json({ access_token: "renewed", refresh_token: "rotated-refresh", expires_in: 3600 }))
yield* test.stop
expect(test.requests[0].url).toBe(`${endpoint}/oauth/token-request`)
expect(Object.fromEntries(new URLSearchParams(yield* Effect.promise(() => test.requests[0].text())))).toEqual({
grant_type: "refresh_token",
refresh_token: "refresh",
client_id: "LOCAL_APPLICATION",
})
expect(test.requests[1].headers.get("authorization")).toBe("Bearer renewed")
expect((yield* credentials.get(saved.id))?.value).toMatchObject({
access: "renewed",
refresh: "rotated-refresh",
metadata: { account: "myorg-myaccount" },
})
}),
),
)
+10 -2
View File
@@ -172,7 +172,11 @@ function getConfig() {
appId,
productName: "OpenCode Beta",
protocols: { name: "OpenCode Beta", schemes: ["opencode"] },
publish: { provider: "github", owner: "anomalyco", repo: "opencode-beta", channel: "latest" },
publish: {
provider: "generic",
url: "https://opencode.ai/update/api/beta/desktop/opencode/",
channel: "latest",
},
deb: { fpm: [metainfoFpm(appId)] },
rpm: { packageName: "opencode-beta", fpm: [metainfoFpm(appId)] },
}
@@ -183,7 +187,11 @@ function getConfig() {
appId,
productName: "OpenCode",
protocols: { name: "OpenCode", schemes: ["opencode"] },
publish: { provider: "github", owner: "anomalyco", repo: "opencode", channel: "latest" },
publish: {
provider: "generic",
url: "https://opencode.ai/update/api/latest/desktop/opencode/",
channel: "latest",
},
deb: { fpm: [metainfoFpm(appId), legacyDesktopEntryFpm] },
rpm: { packageName: "opencode", fpm: [metainfoFpm(appId), legacyDesktopEntryFpm] },
}
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bun
import { Script } from "@opencode/script"
import { $ } from "bun"
import path from "node:path"
import { UpdateArtifact } from "../../../script/update-artifact"
const dryRun = process.argv.includes("--dry-run")
if (!Script.release) {
console.log("skipped desktop publication without a release")
process.exit(0)
}
const repo = process.env.GH_REPO
if (!repo) throw new Error("GH_REPO is required")
const directory = process.env.OPENCODE_DESKTOP_DIST
if (!directory) throw new Error("OPENCODE_DESKTOP_DIST is required")
const tag = `v${Script.version}`
const files = (
await Array.fromAsync(
new Bun.Glob("*.{exe,blockmap,dmg,zip,AppImage,deb,rpm,app.tar.gz}").scan({ cwd: directory, absolute: true }),
)
).sort()
if (!files.length) throw new Error("No desktop release files found")
if (!dryRun) {
await $`gh release upload ${tag} ${files} --clobber --repo ${repo}`
await $`bun ${path.join(import.meta.dir, "finalize-latest-json.ts")}`
await $`bun ${path.join(import.meta.dir, "finalize-latest-yml.ts")}`
}
const uploaded = await UpdateArtifact.upload({ version: Script.version, files, dryRun })
const artifacts = [
{ distribution: "github", metadata: await metadata(Script.version, repo) },
{ distribution: "opencode", metadata: { files: uploaded, ...(await metadata(Script.version, repo, uploaded)) } },
]
if (!dryRun) await $`gh release edit ${tag} --draft=false --repo ${repo}`
for (const artifact of artifacts) {
const input = { ...artifact, channel: Script.channel, name: "desktop", version: Script.version }
if (dryRun) console.log(`dry-run artifact: ${JSON.stringify(input)}`)
if (!dryRun) await UpdateArtifact.publish(input)
}
type DesktopFile = {
url: string
sha512: string
size: number
blockMapSize?: number
}
async function metadata(version: string, repo: string, files?: Record<string, { url: string }>) {
const directory = process.env.RUNNER_TEMP ?? "/tmp"
const entries = await Promise.all(
[
["desktop.yml", "latest.yml"],
["desktop-mac.yml", "latest-mac.yml"],
["desktop-linux.yml", "latest-linux.yml"],
["desktop-linux-arm64.yml", "latest-linux-arm64.yml"],
].map(async ([name, source]) => {
const file = Bun.file(`${directory}/${source}`)
if (!(await file.exists())) return undefined
return [name, parse(await file.text(), version, repo, files)] as const
}),
)
const manifests = Object.fromEntries(entries.filter((entry) => entry !== undefined))
if (!Object.keys(manifests).length) throw new Error("No desktop update metadata found")
return { manifests }
}
function parse(content: string, version: string, repo: string, uploaded?: Record<string, { url: string }>) {
const lines = content.split("\n")
const found = lines
.find((line) => line.startsWith("version:"))
?.slice("version:".length)
.trim()
if (found !== version) throw new Error(`Desktop metadata version mismatch: expected ${version}, got ${found}`)
const releaseDate = lines
.find((line) => line.startsWith("releaseDate:"))
?.slice("releaseDate:".length)
.trim()
.replace(/^['"]|['"]$/g, "")
if (!releaseDate) throw new Error("Desktop metadata did not include a release date")
const files: DesktopFile[] = []
lines.forEach((line) => {
const value = line.trim()
if (value.startsWith("- url:")) {
const name = value.slice("- url:".length).trim()
const filename = name.startsWith("http") ? decodeURIComponent(new URL(name).pathname.split("/").pop()!) : name
const url = uploaded
? uploaded[filename]?.url
: name.startsWith("http")
? name
: `https://github.com/${repo}/releases/download/v${version}/${encodeURIComponent(name)}`
if (!url) throw new Error(`Desktop update file was not uploaded: ${filename}`)
files.push({ url, sha512: "", size: 0 })
return
}
const current = files.at(-1)
if (!current) return
if (value.startsWith("sha512:")) current.sha512 = value.slice("sha512:".length).trim()
if (value.startsWith("size:")) current.size = Number(value.slice("size:".length).trim())
if (value.startsWith("blockMapSize:")) current.blockMapSize = Number(value.slice("blockMapSize:".length).trim())
})
if (!files.length || files.some((file) => !file.sha512 || !file.size))
throw new Error("Desktop metadata contained an incomplete file")
return { files, releaseDate }
}
+332 -72
View File
@@ -1,11 +1,13 @@
import { createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
import { batch, createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
import type { OpenCodeEvent, SessionInfo } from "@opencode/client"
import path from "path"
import { useTerminalDimensions } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import { dialogWidth, useDialog } from "../ui/dialog"
import { DialogSelect, dialogSelectContentWidth } from "../ui/dialog-select"
import { DialogSelect, dialogSelectContentWidth, type DialogSelectRef } from "../ui/dialog-select"
import { DialogPrompt } from "../ui/dialog-prompt"
import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { locationKey, useData } from "../context/data"
import { useClient } from "../context/client"
import { useLocation } from "../context/location"
import { useSessionTabs } from "../context/session-tabs"
@@ -15,6 +17,8 @@ import { Locale } from "../util/locale"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { truncateFilePath } from "../ui/file-path"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
import { stringWidth } from "../util/string-width"
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
import { Spinner } from "./spinner"
@@ -23,7 +27,13 @@ import { projectName } from "../util/project"
const RECENT_LIMIT = 8
export const DialogOpenKey = Symbol("DialogOpen")
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
type OpenTarget =
| { type: "session"; sessionID: string }
| { type: "project"; directory: string; workspaceID?: string; projectID?: string }
type OpenView = { type: "projects" } | { type: "worktrees"; projectID: string; workspaceID?: string }
type OpenSelection = { view: OpenView; filter: string; selected?: OpenTarget }
export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions: SessionInfo[]) => void }) {
const dialog = useDialog()
@@ -32,6 +42,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
const client = useClient()
const location = useLocation()
const sessionTabs = useSessionTabs()
const toast = useToast()
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
@@ -75,6 +86,59 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
() => false,
),
)
const [view, setView] = createSignal<OpenView>({ type: "projects" })
const projectID = () => {
const current = view()
return current.type === "worktrees" ? current.projectID : undefined
}
const [focus, setFocus] = createSignal<OpenTarget>()
const [creation, setCreation] = createSignal<OpenSelection>()
const [creating, setCreating] = createSignal(false)
let projectsSelection: OpenSelection | undefined
let select: DialogSelectRef<OpenTarget> | undefined
let pending: OpenSelection | undefined
function snapshot(): OpenSelection {
return { view: view(), filter: select?.filter ?? "", selected: select?.selected?.value }
}
function restore(next: OpenSelection) {
batch(() => {
setView(next.view)
setSelectionMoved(false)
setFocus(next.selected)
select?.setFilter(next.filter)
})
if (next.selected) select?.moveTo(next.selected)
}
function back() {
if (projectsSelection) restore(projectsSelection)
}
function newWorktree() {
if (!projectID()) return
setCreation(snapshot())
}
function cancelCreation() {
pending = creation()
setCreation(undefined)
}
const workspaceID = () => {
const current = view()
return current.type === "worktrees" ? current.workspaceID : undefined
}
const [worktrees] = createResource(
() => (view().type === "worktrees" ? view() : undefined),
() =>
client.api.worktree
.list({
location: {
directory: data.project.get(projectID()!)!.canonical,
workspace: workspaceID(),
},
})
.catch((error: unknown) => {
toast.show({ title: "Loading worktrees failed", message: errorMessage(error), variant: "error" })
return []
}),
)
const [matched] = createResource(
() => {
@@ -119,15 +183,20 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
const sessionOptions = recent.map((session) => {
const project = data.project.get(session.projectID)
const name = projectName(project)
const basename = path.basename(session.location.directory)
const label =
name && session.location.directory !== project?.canonical && name.toLowerCase() !== basename.toLowerCase()
? `${name} · ${basename}`
: name || basename
const running =
data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
return {
title: withTimestampedFallback(session),
searchText: session.id,
searchText: `${session.id} ${session.location.directory}`,
value: { type: "session", sessionID: session.id } as OpenTarget,
category: "Sessions",
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
footer: `${label ? `${Locale.truncate(label, 30)} · ` : ""}${timeAgo(session.time.updated)}`,
onSelect: () => location.set(session.location),
gutter: running
? (color: RGBA) => <Spinner color={color} />
@@ -137,28 +206,54 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
}
})
const current = location.current?.project
const current = location.ref ?? data.location.default()
const seen = new Set<string>()
const projectOptions = data.project
.list()
.filter((project) => {
if (project.canonical === "/" || seen.has(project.canonical)) return false
seen.add(project.canonical)
const projectOptions = [
...data.project.list().flatMap((project) =>
[project.canonical, ...project.sandboxes].map((directory) => ({
directory,
workspaceID: current.workspaceID,
project,
})),
),
...sessions().map((session) => ({
directory: session.location.directory,
workspaceID: session.location.workspaceID,
project: data.project.get(session.projectID),
})),
]
.filter((item) => {
const key = locationKey(item)
if (item.directory === "/" || seen.has(key)) return false
seen.add(key)
return true
})
.map((project) => {
const title = projectName(project) ?? project.canonical
const footer = abbreviateHome(project.canonical, paths.home)
.map((item) => {
const title =
item.directory === item.project?.canonical
? (projectName(item.project) ?? path.basename(item.directory))
: path.basename(item.directory)
const footer = abbreviateHome(item.directory, paths.home)
const git = item.project?.vcs === "git"
const width =
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) -
stringWidth(title) -
(git ? 2 : 0)
return {
title,
footer: truncateFilePath(footer, width),
searchText: footer,
value: { type: "project", directory: project.canonical } as OpenTarget,
footer: `${truncateFilePath(footer, width)}${git ? " →" : ""}`,
searchText: `${footer} ${projectName(item.project) ?? ""}`,
value: {
type: "project",
directory: item.directory,
...(item.workspaceID ? { workspaceID: item.workspaceID } : {}),
...(git ? { projectID: item.project!.id } : {}),
} as OpenTarget,
category: "Projects",
gutter:
project.canonical === current?.canonical
item.workspaceID === current.workspaceID &&
(item.directory === current.directory ||
(item.directory === location.current?.project.canonical && !seen.has(locationKey(current))))
? () => <text fg={theme.text.formfield.selected}></text>
: undefined,
}
@@ -167,59 +262,224 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
return [...sessionOptions, ...projectOptions]
})
return (
<DialogSelect
title="Open"
placeholder="Search sessions and projects…"
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
sectionNavigation={true}
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onFilter={setFilter}
emptyView={
<Show when={!recent.loading && !projects.loading}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No recent sessions or projects</text>
</box>
</Show>
}
footer={
<box>
<Show when={recent.loading || projects.loading}>
<Spinner color={theme.text.subdued}>Refreshing sessions and projects</Spinner>
</Show>
<Show when={recent() === false || projects() === false}>
<text fg={theme.text.feedback.error.default}>
Could not refresh{" "}
{recent() === false ? (projects() === false ? "sessions and projects" : "sessions") : "projects"}.
</text>
</Show>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
{recent.loading || projects.loading || matched.loading
? "Searching sessions and projects…"
: shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
</text>
</box>
}
onSelect={(option) => {
dialog.clear()
if (option.value.type === "session") {
route.navigate({ type: "session", sessionID: option.value.sessionID })
return
const worktreeOptions = createMemo(() => {
const id = projectID()
if (!id) return []
const project = data.project.get(id)
if (!project) return []
const current = location.ref ?? data.location.default()
const directories = [
project.canonical,
...(worktrees.loading ? [] : (worktrees() ?? [])).map((worktree) => worktree.directory),
]
const width = Math.max(
0,
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) -
Math.max(
...directories.map((directory) =>
stringWidth(
directory === project.canonical
? (projectName(project) ?? path.basename(directory))
: path.basename(directory),
),
),
),
)
return directories
.filter((directory, index) => directories.indexOf(directory) === index)
.toSorted((a, b) => {
if (a === project.canonical) return -1
if (b === project.canonical) return 1
if (a === current.directory) return -1
if (b === current.directory) return 1
return 0
})
.map((directory) => {
const title =
directory === project.canonical
? (projectName(project) ?? path.basename(directory))
: path.basename(directory)
const footer = truncateFilePath(abbreviateHome(directory, paths.home), width)
return {
title,
footer: footer + " ".repeat(Math.max(0, width - stringWidth(footer))),
value: { type: "project", directory, ...(workspaceID() ? { workspaceID: workspaceID() } : {}) } as OpenTarget,
gutter:
directory === current.directory && workspaceID() === current.workspaceID
? () => <text fg={theme.text.formfield.selected}></text>
: undefined,
}
const target = { directory: option.value.directory }
route.navigate({ type: "home", location: target })
location.set(target)
}}
/>
})
})
return (
<box>
<Show
when={creation()}
fallback={
<DialogSelect
ref={(value) => {
select = value
dialog.setSize("large")
if (!pending) return
const previous = pending
pending = undefined
restore(previous)
}}
title={projectID() ? `${projectName(data.project.get(projectID()!)) ?? "Project"} / Worktrees` : "Open"}
placeholder={projectID() ? "Search worktrees…" : "Search sessions and projects…"}
options={projectID() ? worktreeOptions() : options()}
current={
currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined
}
focusTarget={focus()}
focusCurrent={focus() !== undefined}
sectionNavigation={true}
preserveSelection={selectionMoved()}
onMove={() => {
setSelectionMoved(true)
}}
onCancel={view().type === "projects" ? undefined : back}
onFilter={setFilter}
emptyView={
<Show when={!recent.loading && !projects.loading}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No recent sessions or projects</text>
</box>
</Show>
}
footer={
<Show
when={
projectID()
? worktrees.loading
: recent.loading || projects.loading || recent() === false || projects() === false
}
>
<box>
<Show when={projectID() && worktrees.loading}>
<Spinner color={theme.text.subdued}>Loading worktrees</Spinner>
</Show>
<Show when={!projectID() && (recent.loading || projects.loading)}>
<Spinner color={theme.text.subdued}>Refreshing sessions and projects</Spinner>
</Show>
<Show when={!projectID() && (recent() === false || projects() === false)}>
<text fg={theme.text.feedback.error.default}>
Could not refresh{" "}
{recent() === false ? (projects() === false ? "sessions and projects" : "sessions") : "projects"}.
</text>
</Show>
</box>
</Show>
}
bindings={[
...(!projectID()
? [
{
bind: "right",
title: "Show project worktrees",
group: "Dialog",
run: () => {
const target = select?.selected?.value
if (target?.type !== "project" || !target.projectID) return
projectsSelection = snapshot()
restore({
view: { type: "worktrees", projectID: target.projectID, workspaceID: target.workspaceID },
filter: "",
selected: {
type: "project",
directory: target.directory,
...(target.workspaceID ? { workspaceID: target.workspaceID } : {}),
},
})
},
},
]
: []),
...(projectID()
? [
{
bind: "left",
title: "Return to projects",
group: "Dialog",
run: back,
},
{ bind: "ctrl+n", title: "New worktree", group: "Dialog", run: newWorktree },
]
: []),
]}
footerHints={[...(projectID() ? [{ title: "new worktree", label: "ctrl+n" }] : [])]}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
{projectID()
? worktrees.loading
? "Loading worktrees…"
: "No matching worktrees"
: recent.loading || projects.loading || matched.loading
? "Searching sessions and projects…"
: shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
</text>
</box>
}
onSelect={(option) => {
dialog.clear()
if (option.value.type === "session") {
route.navigate({ type: "session", sessionID: option.value.sessionID })
return
}
const target = {
directory: option.value.directory,
...(option.value.workspaceID ? { workspaceID: option.value.workspaceID } : {}),
}
route.navigate({ type: "home", location: target })
location.set(target)
}}
/>
}
>
<DialogPrompt
size="large"
title={`${projectName(data.project.get(projectID()!)) ?? "Project"} / New worktree`}
placeholder="Worktree name (optional)"
description={() => <text fg={theme.text.subdued}>Leave blank for a random name.</text>}
busy={creating()}
busyText="Creating worktree…"
onCancel={cancelCreation}
onConfirm={(value) => {
const id = projectID()!
const previous = creation()
setCreating(true)
void client.api.worktree
.create({
location: {
directory: data.project.get(id)!.canonical,
workspace: workspaceID(),
},
strategy: "git",
directory: path.join(paths.worktree, id.slice(0, 6)),
...(value.trim() ? { name: value.trim() } : {}),
})
.then((created) => {
if (closed || creation() !== previous) return
const target = {
directory: created.directory,
...(workspaceID() ? { workspaceID: workspaceID() } : {}),
}
dialog.clear()
route.navigate({ type: "home", location: target })
location.set(target)
})
.catch((error: unknown) =>
toast.show({ title: "Creating worktree failed", message: errorMessage(error), variant: "error" }),
)
.finally(() => setCreating(false))
}}
/>
</Show>
</box>
)
}
+31 -3
View File
@@ -1,13 +1,15 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog } from "./dialog"
import { useDialog, type DialogSize } from "./dialog"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
import { useConfig } from "../config"
import { useRenderer } from "@opentui/solid"
export type DialogPromptProps = {
title: string
size?: DialogSize
description?: () => JSX.Element
placeholder?: string
value?: string
@@ -19,6 +21,7 @@ export type DialogPromptProps = {
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const renderer = useRenderer()
const theme = useTheme("elevated")
const shortcuts = Keymap.useShortcuts()
const config = useConfig().data
@@ -46,8 +49,28 @@ export function DialogPrompt(props: DialogPromptProps) {
],
}))
Keymap.createLayer(() => ({
mode: "modal",
enabled: props.onCancel !== undefined,
priority: 1,
commands: [
{
bind: "escape",
title: "Back",
group: "Dialog",
run: () => {
if (renderer.getSelection()) {
renderer.clearSelection()
return
}
if (!props.busy) props.onCancel?.()
},
},
],
}))
onMount(() => {
dialog.setSize("medium")
dialog.setSize(props.size ?? "medium")
setTimeout(() => {
if (!textarea || textarea.isDestroyed) return
if (props.busy) return
@@ -78,7 +101,12 @@ export function DialogPrompt(props: DialogPromptProps) {
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text
fg={theme.text.subdued}
onMouseUp={() => {
if (!props.busy) (props.onCancel ?? dialog.clear)()
}}
>
esc
</text>
</box>
+55 -14
View File
@@ -2,7 +2,7 @@ import { CliRenderEvents, InputRenderable, RGBA, ScrollBoxRenderable, TextAttrib
import { Keymap, type KeymapCommand } from "../context/keymap"
import { useTheme, useThemes } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
@@ -27,6 +27,7 @@ export interface DialogSelectProps<T> {
onMove?: (option: DialogSelectOption<T>) => void
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
onCancel?: () => void
skipFilter?: boolean
renderFilter?: boolean
locked?: boolean
@@ -39,6 +40,7 @@ export interface DialogSelectProps<T> {
}[]
bindings?: readonly KeymapCommand[]
current?: T
focusTarget?: T
focusCurrent?: boolean
sectionNavigation?: boolean
}
@@ -93,6 +95,8 @@ export function dialogSelectContentWidth(dialogWidth: number) {
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
selected: DialogSelectOption<T> | undefined
setFilter(value: string): void
moveTo(value: T): void
}
@@ -132,21 +136,23 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
createEffect(
on(
() => props.current,
(current) => {
[() => props.focusTarget ?? props.current, () => (props.focusTarget === undefined ? undefined : flat())],
([current]) => {
if (props.focusCurrent === false) return
if (props.focusTarget !== undefined && (props.preserveSelection || store.filter.length > 0)) return
if (current !== undefined) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) {
setStore("selected", currentIndex)
selection = flat()[currentIndex]
scrollAfterLayout(true, current)
}
}
},
),
)
let input: InputRenderable
let input: InputRenderable | undefined
const actions = createMemo(() => props.actions ?? [])
const shownActions = createMemo(() => actions().filter((item) => !item.hidden))
@@ -242,7 +248,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
on(
() => props.options,
() => {
if (!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) {
if (
!props.preserveSelection &&
((props.focusTarget ?? props.current) === undefined || props.focusCurrent === false)
) {
const count = flat().length
if (count === 0) return
const next = reconcileSelection(store.selected, count)
@@ -257,8 +266,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return
}
if (!selection) {
if (props.focusCurrent !== false && props.current !== undefined) {
const index = flat().findIndex((option) => isDeepEqual(option.value, props.current))
if (props.focusCurrent !== false && (props.focusTarget ?? props.current) !== undefined) {
const index = flat().findIndex((option) => isDeepEqual(option.value, props.focusTarget ?? props.current))
if (index >= 0) {
setStore("selected", index)
selection = flat()[index]
@@ -280,7 +289,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (!moved) return
if (
!props.preserveSelection &&
(props.current === undefined || props.focusCurrent === false || store.filter.length > 0)
((props.focusTarget ?? props.current) === undefined ||
props.focusCurrent === false ||
store.filter.length > 0)
)
return
scrollAfterLayout(false, option.value)
@@ -300,7 +311,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})
createEffect(
on([() => store.filter, () => props.current], ([filter, current]) => {
on([() => store.filter, () => props.focusTarget ?? props.current], ([filter, current]) => {
if (filter.length > 0) resetSelection = true
if (filter.length > 0) {
const option = flat()[0]
@@ -486,6 +497,22 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
]
: []),
...(props.bindings ?? []),
...(props.onCancel
? [
{
bind: "escape",
title: "Back",
group: "Dialog",
run: () => {
if (renderer.getSelection()) {
renderer.clearSelection()
return
}
props.onCancel?.()
},
},
]
: []),
...(props.sectionNavigation
? [
{
@@ -514,12 +541,26 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
get filtered() {
return filtered()
},
get selected() {
return selected()
},
setFilter(value) {
if (input) {
input.value = value
return
}
if (value === store.filter) return
batch(() => {
setStore("filter", value)
props.onFilter?.(value)
})
},
moveTo(value) {
const index = flat().findIndex((option) => isDeepEqual(option.value, value))
if (index >= 0) moveTo(index, true)
},
}
props.ref?.(ref)
onMount(() => props.ref?.(ref))
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
@@ -606,7 +647,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
{props.title}
</text>
)}
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={() => (props.onCancel ?? dialog.clear)()}>
esc
</text>
</box>
@@ -629,8 +670,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
input.traits = { status: "FILTER" }
setTimeout(() => {
if (!input) return
if (input.isDestroyed) return
input.focus()
if (r.isDestroyed) return
r.focus()
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
@@ -774,7 +815,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</box>
<Show when={props.footer || visibleActions().length} fallback={<box flexShrink={0} />}>
<box paddingRight={2} paddingLeft={4} flexDirection="row" justifyContent="space-between" flexShrink={0}>
<box flexDirection="row" gap={2}>
<box flexDirection={dimensions().width < 60 ? "column" : "row"} gap={dimensions().width < 60 ? 0 : 2}>
{props.footer}
<For each={left()}>{(item) => <FooterAction item={item} />}</For>
</box>

Some files were not shown because too many files have changed in this diff Show More