mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 09:56:24 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50e17b7f95 | ||
|
|
e8177238f6 | ||
|
|
b3f36c0967 | ||
|
|
1f77408ff2 | ||
|
|
a912a6ee4f | ||
|
|
2ac698d65a | ||
|
|
c1c6ab593d | ||
|
|
d1d1c6f890 | ||
|
|
0ab661a9cc | ||
|
|
a55dc8c84a |
@@ -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 }}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -11,6 +11,7 @@ on:
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
- "packages/*/package.json"
|
||||
- "services/*/package.json"
|
||||
- "flake.lock"
|
||||
- "nix/node_modules.nix"
|
||||
- "nix/scripts/**"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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/*"
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { model, type Settings } from "../zai.js"
|
||||
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
+74
File diff suppressed because one or more lines are too long
packages/ai/test/fixtures/recordings/moonshot-chat/kimi-k2-6-streams-text-with-default-thinking.json
Vendored
+29
File diff suppressed because one or more lines are too long
+29
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
packages/ai/test/fixtures/recordings/moonshot-chat/kimi-k2-6-streams-text-with-enabled-thinking.json
Vendored
+29
File diff suppressed because one or more lines are too long
+74
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
+74
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
+74
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
Vendored
+29
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
+74
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
packages/ai/test/fixtures/recordings/moonshot-responses/k3-rejects-unsupported-tool-choice-none.json
Vendored
+29
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
+74
File diff suppressed because one or more lines are too long
Vendored
+28
File diff suppressed because one or more lines are too long
+28
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+64
File diff suppressed because one or more lines are too long
+27
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
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" } },
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -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`))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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] },
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user